From 642f433bcb2152e001c176f82af396d3d4ea34b0 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 9 Feb 2026 14:18:40 +0900 Subject: [PATCH 01/55] =?UTF-8?q?docs:=20POP=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EC=9D=98=EC=84=9C=20v7.0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 9개 컴포넌트 정의 (pop-text, pop-dashboard, pop-table, pop-button, pop-icon, pop-search, pop-field, pop-lookup, pop-system) - POP 헌법 (공통 규칙 8조) 추가 - 공통 인프라 설계 (DataSourceConfig, ColumnBinding, JoinConfig, useDataSource, usePopEvent, PopActionConfig) - 컴포넌트 간 통신 예시 4가지 - 구현 우선순위 Phase 0~6 Co-authored-by: Cursor --- POPUPDATE_2.md | 447 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 POPUPDATE_2.md diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md new file mode 100644 index 00000000..cc08d883 --- /dev/null +++ b/POPUPDATE_2.md @@ -0,0 +1,447 @@ +# POP 컴포넌트 정의서 v7.0 + +## POP 헌법 (공통 규칙) + +### 제1조. 컴포넌트의 정의 + +- 컴포넌트란 디자이너가 그리드에 배치하는 것이다 +- 그리드에 배치하지 않는 것은 컴포넌트가 아니다 + +### 제2조. 컴포넌트의 독립성 + +- 모든 컴포넌트는 독립적으로 동작한다 +- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신) + +### 제3조. 데이터의 자유 + +- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다 +- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다 +- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다 + +### 제4조. 통신의 규칙 + +- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다 +- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다 +- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다) +- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다 + +### 제5조. 역할의 분리 + +- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다 +- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다 +- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다 + +### 제6조. 시스템 설정도 컴포넌트다 + +- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다 +- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다 +- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다 + +### 제7조. 디자이너의 권한 + +- 디자이너는 컴포넌트를 배치하고 설정한다 +- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable) +- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다 +- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다 + +### 제8조. 컴포넌트의 구성 + +- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널 +- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다 +- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다 + +--- + +## 현재 상태 + +- 그리드 시스템 (v5.2): 완성 +- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts) +- 구현 완료: `pop-text` 1개 (pop-text.tsx) +- 기존 `components-spec.md`는 v4 기준이라 갱신 필요 + +## 아키텍처 개요 + +```mermaid +graph TB + subgraph designer [디자이너] + Palette[컴포넌트 팔레트] + Grid[CSS Grid 캔버스] + ConfigPanel[속성 설정 패널] + end + + subgraph registry [레지스트리] + Registry[PopComponentRegistry] + end + + subgraph infra [공통 인프라] + DataSource[useDataSource 훅] + EventBus[usePopEvent 훅] + ActionRunner[usePopAction 훅] + end + + subgraph components [9개 컴포넌트] + Text[pop-text - 완성] + Dashboard[pop-dashboard] + Table[pop-table] + Button[pop-button] + Icon[pop-icon] + Search[pop-search] + Field[pop-field] + Lookup[pop-lookup] + System[pop-system] + end + + subgraph backend [기존 백엔드 API] + DataAPI[dataApi - 동적 CRUD] + DashAPI[dashboardApi - 통계 쿼리] + CodeAPI[commonCodeApi - 공통코드] + NumberAPI[numberingRuleApi - 채번] + end + + Palette --> Grid + Grid --> ConfigPanel + ConfigPanel --> Registry + + Registry --> components + components --> infra + infra --> backend + EventBus -.->|컴포넌트 간 통신| components + System -.->|보이기/숨기기 제어| components +``` + +--- + +## 공통 인프라 (모든 컴포넌트가 공유) + +### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다 + +1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능 +2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성 +3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능 +4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능 + +### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능) + +디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성: + +- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출) +- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적") + +### 1. DataSourceConfig (데이터 소스 설정 타입) + +모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조: + +- `tableName`: 대상 테이블 +- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열) +- `filters`: 필터 조건 배열 +- `sort`: 정렬 설정 +- `aggregation`: 집계 함수 (count, sum, avg, min, max) +- `joins`: 테이블 조인 설정 (JoinConfig 배열) +- `refreshInterval`: 자동 새로고침 주기 (초) +- `limit`: 조회 건수 제한 + +### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어) + +각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정: + +- `columnName`: 컬럼명 +- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함) +- `mode`: "read" | "write" | "readwrite" | "hidden" + - read: 조회만 (화면에 표시하되 저장 안 함) + - write: 저장 대상 (사용자 입력 -> DB 저장) + - readwrite: 조회 + 저장 모두 + - hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능) +- `label`: 화면 표시 라벨 +- `defaultValue`: 기본값 + +예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장 + +``` +columns: [ + { columnName: "item_code", sourceTable: "order_items", mode: "read" }, + { columnName: "item_name", sourceTable: "item_info", mode: "read" }, + { columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" }, + { columnName: "warehouse", sourceTable: "order_items", mode: "write" }, + { columnName: "memo", sourceTable: "order_items", mode: "write" }, +] +``` + +### 1-2. JoinConfig (테이블 조인 설정) + +외부 테이블과 자유롭게 조인: + +- `targetTable`: 조인할 외부 테이블명 +- `joinType`: "inner" | "left" | "right" +- `on`: 조인 조건 { sourceColumn, targetColumn } +- `columns`: 가져올 컬럼 목록 + +### 2. useDataSource 훅 + +DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환: + +- 로딩/에러/데이터 상태 관리 +- 자동 새로고침 타이머 +- 필터 변경 시 자동 재조회 +- 기존 `dataApi`, `dashboardApi` 활용 +- **CRUD 함수 제공**: save(data), update(id, data), delete(id) + - ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함 + - "read" 컬럼은 저장 시 자동 제외 + +### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함) + +컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드): + +- `publish(eventName, payload)`: 이벤트 발행 +- `subscribe(eventName, callback)`: 이벤트 구독 +- `getSharedData(key)`: 공유 데이터 직접 읽기 +- `setSharedData(key, value)`: 공유 데이터 직접 쓰기 +- 화면 단위 스코프 (다른 POP 화면과 격리) + +### 4. PopActionConfig (액션 설정 타입) + +모든 컴포넌트가 사용할 수 있는 액션 표준 구조: + +- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh" +- `navigate`: { screenId, url } +- `modal`: { title, dataSource } +- `save`: { targetColumns } +- `delete`: { confirmMessage } +- `api`: { method, endpoint, body } +- `event`: { eventName, payload } +- `refresh`: { targetComponents } + +--- + +## 컴포넌트 정의 (9개) + +### 1. pop-text (완성) + +- **한 줄 정의**: 보여주기만 함 +- **카테고리**: display +- **역할**: 정적 표시 전용 (이벤트 없음) +- **서브타입**: text, datetime, image, title +- **데이터**: 없음 (정적 콘텐츠) +- **이벤트**: 발행 없음, 수신 없음 +- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더 + +### 2. pop-dashboard (신규) + +- **한 줄 정의**: 숫자를 집계해서 보여줌 +- **카테고리**: display +- **역할**: 숫자 데이터를 집계/계산하여 시각화 +- **서브타입**: + - kpi-card: 숫자 + 단위 + 라벨 + 증감 표시 + - chart: 막대/원형/라인 차트 + - gauge: 게이지 (목표 대비 달성률) + - stat-card: 통계 카드 (건수 + 대기 + 링크) +- **데이터**: DataSourceConfig (조인/집계 자유) +- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능 +- **이벤트**: + - 수신: filter_changed, data_ready + - 발행: kpi_clicked (KPI 카드 클릭 시 상세 데이터 전달) +- **설정**: 데이터 소스, 집계 함수, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드(slide/scroll/grid) + +### 3. pop-table (신규 - 가장 복잡) + +- **한 줄 정의**: 데이터 목록을 보여주고 편집함 +- **카테고리**: display +- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형) +- **서브타입**: + - card-list: 카드 형태 + - table-list: 테이블 형태 (행/열 장부) +- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유) +- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출 +- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩 +- **이벤트**: + - 수신: filter_changed, refresh, data_ready + - 발행: row_selected, row_action, save_complete, delete_complete +- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부 + +### 4. pop-button (신규) + +- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등) +- **카테고리**: action +- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등) +- **데이터**: 이벤트로 수신한 데이터를 액션에 활용 +- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행 +- **이벤트**: + - 수신: data_ready, row_selected + - 발행: save_complete, delete_complete 등 +- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태 + +### 5. pop-icon (신규) + +- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음) +- **카테고리**: action +- **역할**: 네비게이션 (화면 이동, URL 이동, 새로고침) +- **데이터**: 없음 +- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행) +- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시 +- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음 + +### 6. pop-search (신규) + +- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링 +- **카테고리**: input +- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회 +- **서브타입**: + - text-search: 텍스트 검색 + - date-range: 날짜 범위 + - select-filter: 드롭다운 선택 (공통코드 연동) + - combo-filter: 복합 필터 (여러 조건 조합) +- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시) +- **데이터**: 공통코드/카테고리 API로 선택 항목 조회 +- **이벤트**: + - 수신: 없음 + - 발행: filter_changed (필터 값 변경 시) +- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름 +- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감) + +### 7. pop-field (신규) + +- **한 줄 정의**: 저장할 값을 입력 +- **카테고리**: input +- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적 +- **서브타입**: + - text: 텍스트 입력 + - number: 숫자 입력 (수량, 금액) + - date: 날짜 선택 + - select: 드롭다운 선택 + - numpad: 큰 숫자패드 (현장용) +- **데이터**: DataSourceConfig (선택적) + - select 옵션을 DB에서 조회 가능 + - ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정 +- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달 +- **이벤트**: + - 수신: set_value (외부에서 값 설정) + - 발행: value_changed (값 + 컬럼명 + 모드 정보) +- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼 + +### 8. pop-lookup (신규) + +- **한 줄 정의**: 모달에서 값을 골라서 반환 +- **카테고리**: input +- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트 +- **서브타입 (모달 안 표시 방식)**: + - card: 카드형 목록 + - table: 테이블형 목록 + - icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼) +- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행 +- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스) +- **이벤트**: + - 수신: set_value (외부에서 값 초기화) + - 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달) +- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름 +- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌 +- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택 + +### 9. pop-system (신규) + +- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기) +- **카테고리**: system +- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트 +- **내부 포함 기능**: + - 프로필 표시 (사용자명, 부서) + - 테마 선택 (기본/다크/블루/그린) + - 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집) + - 하단 메뉴 보이기/숨기기 + - 드래그앤드롭으로 순서 변경 +- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치 +- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경 +- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집 +- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름) +- **이벤트**: + - 수신: 없음 + - 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시) +- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만 +- **특이사항**: + - 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다 + - 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조 + - 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용 + +--- + +## 컴포넌트 간 통신 예시 + +### 예시 1: 검색 -> 필터 연동 + +```mermaid +sequenceDiagram + participant Search as pop-search + participant Dashboard as pop-dashboard + participant Table as pop-table + + Note over Search: 사용자가 창고 WH01 선택 + Search->>Dashboard: filter_changed + Search->>Table: filter_changed + Note over Dashboard: DataSource 재조회 + Note over Table: DataSource 재조회 +``` + +### 예시 2: 데이터 전달 + 선택적 저장 + +```mermaid +sequenceDiagram + participant Table as pop-table + participant Field as pop-field + participant Button as pop-button + + Note over Table: 사용자가 발주 행 선택 + Table->>Field: row_selected + Table->>Button: row_selected + Note over Field: 사용자가 qty를 500으로 입력 + Field->>Button: value_changed + Note over Button: 사용자가 저장 클릭 + Note over Button: write/readwrite 컬럼만 추출하여 저장 + Button->>Table: save_complete + Note over Table: 데이터 새로고침 +``` + +### 예시 3: pop-lookup 거래처 선택 -> 품목 조회 + +```mermaid +sequenceDiagram + participant Lookup as pop-lookup + participant Table as pop-table + + Note over Lookup: 사용자가 거래처 필드 클릭 + Note over Lookup: 모달 열림 - 거래처 목록 표시 + Note over Lookup: 사용자가 대한금속 선택 + Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시 + Lookup->>Table: filter_changed { company: "대한금속" } + Note over Table: company=대한금속 필터로 재조회 + Note over Table: 발주 품목 3건 표시 +``` + +### 예시 4: 컬럼별 읽기/쓰기 분리 동작 + +5개 컬럼이 있는 발주 화면: + +- item_code (read) -> 화면에 표시, 저장 안 함 +- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함 +- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장 +- warehouse (write) -> 사용자 입력 + 저장 +- memo (write) -> 사용자 입력 + 저장 + +저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달 +조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회 + +--- + +## 구현 우선순위 + +- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입 +- Phase 1 (기본 표시): pop-dashboard (KPI 카드 서브타입부터) +- Phase 2 (기본 액션): pop-button, pop-icon +- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위) +- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup +- Phase 5 (고도화): pop-table 카드 템플릿, 차트, 게이지 +- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합) + +## 참고 파일 + +- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts` +- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx` +- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts` +- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts` +- 기존 스펙 (v4): `popdocs/components-spec.md` From a017c1352a41e8a0468acd285d15a74c9ad840cf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 9 Feb 2026 18:52:33 +0900 Subject: [PATCH 02/55] =?UTF-8?q?chore:=20popdocs=20=ED=8F=B4=EB=8D=94=20g?= =?UTF-8?q?it=20=EC=B6=94=EC=A0=81=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개인 작업 문서(popdocs/)를 .gitignore에 추가하고 추적 해제 Co-authored-by: Cursor --- .gitignore | 5 +- popdocs/ARCHITECTURE.md | 286 ---- popdocs/CHANGELOG.md | 1205 ----------------- popdocs/FILES.md | 646 --------- popdocs/INDEX.md | 120 -- popdocs/PLAN.md | 150 -- popdocs/PROBLEMS.md | 254 ---- popdocs/README.md | 110 -- popdocs/SAVE_RULES.md | 574 -------- popdocs/SPEC.md | 236 ---- popdocs/STATUS.md | 126 -- popdocs/WORKFLOW_PROMPTS.md | 302 ----- popdocs/archive/BUGFIX_CANVAS_ROWS.md | 227 ---- popdocs/archive/COMPONENT_ROADMAP.md | 389 ------ popdocs/archive/GRID_CODING_PLAN.md | 763 ----------- popdocs/archive/GRID_SYSTEM_DESIGN.md | 329 ----- popdocs/archive/GRID_SYSTEM_PLAN.md | 480 ------- popdocs/archive/PHASE3_SUMMARY.md | 518 ------- popdocs/archive/POPREADME.md | 658 --------- popdocs/archive/POPUPDATE.md | 1041 -------------- .../archive/POP_V4_CONSTRAINT_SYSTEM_PLAN.md | 760 ----------- popdocs/archive/PROJECT_ARCHITECTURE.md | 285 ---- popdocs/archive/RESPONSIVE_DESIGN_GUIDE.md | 153 --- popdocs/archive/SIZE_PRESETS.md | 205 --- popdocs/archive/STORAGE_RULES.md | 183 --- popdocs/archive/V4_CORE_RULES.md | 240 ---- popdocs/archive/V4_UNIFIED_DESIGN_SPEC.md | 391 ------ popdocs/components-spec.md | 461 ------- popdocs/decisions/001-v4-constraint-based.md | 119 -- .../decisions/002-phase3-visibility-break.md | 690 ---------- popdocs/decisions/003-v5-grid-system.md | 143 -- .../decisions/004-grid-guide-integration.md | 143 -- popdocs/decisions/005-breakpoint-redesign.md | 181 --- .../decisions/006-auto-wrap-review-system.md | 220 --- popdocs/sessions/2026-02-05.md | 177 --- popdocs/sessions/2026-02-06.md | 239 ---- 36 files changed, 4 insertions(+), 13005 deletions(-) delete mode 100644 popdocs/ARCHITECTURE.md delete mode 100644 popdocs/CHANGELOG.md delete mode 100644 popdocs/FILES.md delete mode 100644 popdocs/INDEX.md delete mode 100644 popdocs/PLAN.md delete mode 100644 popdocs/PROBLEMS.md delete mode 100644 popdocs/README.md delete mode 100644 popdocs/SAVE_RULES.md delete mode 100644 popdocs/SPEC.md delete mode 100644 popdocs/STATUS.md delete mode 100644 popdocs/WORKFLOW_PROMPTS.md delete mode 100644 popdocs/archive/BUGFIX_CANVAS_ROWS.md delete mode 100644 popdocs/archive/COMPONENT_ROADMAP.md delete mode 100644 popdocs/archive/GRID_CODING_PLAN.md delete mode 100644 popdocs/archive/GRID_SYSTEM_DESIGN.md delete mode 100644 popdocs/archive/GRID_SYSTEM_PLAN.md delete mode 100644 popdocs/archive/PHASE3_SUMMARY.md delete mode 100644 popdocs/archive/POPREADME.md delete mode 100644 popdocs/archive/POPUPDATE.md delete mode 100644 popdocs/archive/POP_V4_CONSTRAINT_SYSTEM_PLAN.md delete mode 100644 popdocs/archive/PROJECT_ARCHITECTURE.md delete mode 100644 popdocs/archive/RESPONSIVE_DESIGN_GUIDE.md delete mode 100644 popdocs/archive/SIZE_PRESETS.md delete mode 100644 popdocs/archive/STORAGE_RULES.md delete mode 100644 popdocs/archive/V4_CORE_RULES.md delete mode 100644 popdocs/archive/V4_UNIFIED_DESIGN_SPEC.md delete mode 100644 popdocs/components-spec.md delete mode 100644 popdocs/decisions/001-v4-constraint-based.md delete mode 100644 popdocs/decisions/002-phase3-visibility-break.md delete mode 100644 popdocs/decisions/003-v5-grid-system.md delete mode 100644 popdocs/decisions/004-grid-guide-integration.md delete mode 100644 popdocs/decisions/005-breakpoint-redesign.md delete mode 100644 popdocs/decisions/006-auto-wrap-review-system.md delete mode 100644 popdocs/sessions/2026-02-05.md delete mode 100644 popdocs/sessions/2026-02-06.md diff --git a/.gitignore b/.gitignore index a771d2c9..f9c578ad 100644 --- a/.gitignore +++ b/.gitignore @@ -286,4 +286,7 @@ uploads/ *.hwp *.hwpx -claude.md \ No newline at end of file +claude.md + +# 개인 작업 문서 (popdocs) +popdocs/ \ No newline at end of file diff --git a/popdocs/ARCHITECTURE.md b/popdocs/ARCHITECTURE.md deleted file mode 100644 index 8acbf24b..00000000 --- a/popdocs/ARCHITECTURE.md +++ /dev/null @@ -1,286 +0,0 @@ -# POP 화면 시스템 아키텍처 - -**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)** - -POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다. - ---- - -## 현재 버전: v5 (CSS Grid) - -| 항목 | v5 (현재) | -|------|----------| -| 레이아웃 | CSS Grid | -| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) | -| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) | -| 칸 수 | 4/6/8/12칸 | - ---- - -## 폴더 구조 - -``` -frontend/ -├── app/(pop)/ # Next.js App Router -│ ├── layout.tsx # POP 전용 레이아웃 -│ └── pop/ -│ ├── page.tsx # 대시보드 -│ ├── screens/[screenId]/ # 화면 뷰어 (v5) -│ └── work/ # 작업 화면 -│ -├── components/pop/ # POP 컴포넌트 -│ ├── designer/ # 디자이너 모듈 ★ -│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장) -│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드) -│ │ ├── panels/ -│ │ │ └── ComponentEditorPanel.tsx # 속성 편집 -│ │ ├── renderers/ -│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링 -│ │ ├── types/ -│ │ │ └── pop-layout.ts # v5 타입 정의 -│ │ └── utils/ -│ │ └── gridUtils.ts # 위치 계산 -│ ├── management/ # 화면 관리 -│ └── dashboard/ # 대시보드 -│ -└── lib/ - ├── api/screen.ts # 화면 API - └── registry/ # 컴포넌트 레지스트리 -``` - ---- - -## 핵심 파일 - -### 1. PopDesigner.tsx (메인) - -**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 - -```typescript -// 상태 관리 -const [layout, setLayout] = useState(createEmptyPopLayoutV5()); -const [selectedComponentId, setSelectedComponentId] = useState(null); -const [currentMode, setCurrentMode] = useState("tablet_landscape"); -const [history, setHistory] = useState([]); - -// 핵심 함수 -handleSave() // 레이아웃 저장 -handleAddComponent() // 컴포넌트 추가 -handleUpdateComponent() // 컴포넌트 수정 -handleDeleteComponent() // 컴포넌트 삭제 -handleUndo() / handleRedo() // 히스토리 -``` - -### 2. PopCanvas.tsx (캔버스) - -**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환 - -```typescript -// DnD 설정 -const DND_ITEM_TYPES = { COMPONENT: "component" }; - -// 뷰포트 프리셋 (4개 모드) - height 제거됨 (세로 무한 스크롤) -const VIEWPORT_PRESETS = [ - { id: "mobile_portrait", width: 375, columns: 4 }, - { id: "mobile_landscape", width: 600, columns: 6 }, - { id: "tablet_portrait", width: 834, columns: 8 }, - { id: "tablet_landscape", width: 1024, columns: 12 }, -]; - -// 세로 자동 확장 -const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 -const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수 -const dynamicCanvasHeight = useMemo(() => { ... }, []); - -// 기능 -- useDrop(): 팔레트에서 컴포넌트 드롭 -- handleWheel(): 줌 (30%~150%) -- Space + 드래그: 패닝 -``` - -### 3. PopRenderer.tsx (렌더러) - -**역할**: CSS Grid 기반 레이아웃 렌더링 - -```typescript -// Props -interface PopRendererProps { - layout: PopLayoutDataV5; - viewportWidth: number; - currentMode: GridMode; - isDesignMode: boolean; - selectedComponentId?: string | null; - onSelectComponent?: (id: string | null) => void; -} - -// CSS Grid 스타일 생성 -const gridStyle = useMemo(() => ({ - display: "grid", - gridTemplateColumns: `repeat(${columns}, 1fr)`, - gridTemplateRows: `repeat(${rows}, 1fr)`, - gap: `${gap}px`, - padding: `${padding}px`, -}), [mode]); - -// 위치 변환 (12칸 → 다른 모드) -const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => { - const ratio = GRID_BREAKPOINTS[targetMode].columns / 12; - return { - col: Math.max(1, Math.round(pos.col * ratio)), - colSpan: Math.max(1, Math.round(pos.colSpan * ratio)), - row: pos.row, - rowSpan: pos.rowSpan, - }; -}; -``` - -### 4. ComponentEditorPanel.tsx (속성 패널) - -**역할**: 선택된 컴포넌트 속성 편집 - -```typescript -// 탭 구조 -- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집) -- settings: label, type 등 -- data: 데이터 바인딩 (미구현) -- visibility: 모드별 표시/숨김 -``` - -### 5. pop-layout.ts (타입 정의) - -**역할**: v5 타입 정의 - -```typescript -// 그리드 모드 -type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - -// 브레이크포인트 설정 (2026-02-06 재설계) -const GRID_BREAKPOINTS = { - mobile_portrait: { columns: 4, maxWidth: 479, gap: 8, padding: 12 }, - mobile_landscape: { columns: 6, minWidth: 480, maxWidth: 767, gap: 8, padding: 16 }, - tablet_portrait: { columns: 8, minWidth: 768, maxWidth: 1023, gap: 12, padding: 20 }, - tablet_landscape: { columns: 12, minWidth: 1024, gap: 12, padding: 24 }, -}; - -// 모드 감지 (순수 너비 기반) -function detectGridMode(viewportWidth: number): GridMode { - if (viewportWidth < 480) return "mobile_portrait"; - if (viewportWidth < 768) return "mobile_landscape"; - if (viewportWidth < 1024) return "tablet_portrait"; - return "tablet_landscape"; -} - -// 레이아웃 데이터 -interface PopLayoutDataV5 { - version: "pop-5.0"; - metadata: PopLayoutMetadata; - gridConfig: PopGridConfig; - components: PopComponentDefinitionV5[]; - globalSettings: PopGlobalSettingsV5; -} - -// 컴포넌트 정의 -interface PopComponentDefinitionV5 { - id: string; - type: PopComponentType; - label: string; - gridPosition: PopGridPosition; // col, row, colSpan, rowSpan - config: PopComponentConfig; - visibility: Record; - modeOverrides?: Record; -} - -// 위치 -interface PopGridPosition { - col: number; // 시작 열 (1부터) - row: number; // 시작 행 (1부터) - colSpan: number; // 열 크기 (1~12) - rowSpan: number; // 행 크기 (1~) -} -``` - -### 6. gridUtils.ts (유틸리티) - -**역할**: 그리드 위치 계산 - -```typescript -// 위치 변환 -convertPositionToMode(pos, targetMode) - -// 겹침 감지 -isOverlapping(posA, posB) - -// 빈 위치 찾기 -findNextEmptyPosition(layout, mode) - -// 마우스 → 그리드 좌표 -mouseToGridPosition(mouseX, mouseY, canvasRect, mode) -``` - ---- - -## 데이터 흐름 - -``` -[사용자 액션] - ↓ -[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history) - ↓ -[PopCanvas] ← DnD, 줌, 모드 전환 - ↓ -[PopRenderer] ← CSS Grid 렌더링 - ↓ -[컴포넌트 표시] -``` - -### 저장 흐름 - -``` -[저장 버튼] - ↓ -PopDesigner.handleSave() - ↓ -screenApi.saveLayoutPop(screenId, layout) - ↓ -[백엔드] screenManagementService.saveLayoutPop() - ↓ -[DB] screen_layouts_pop 테이블 -``` - -### 로드 흐름 - -``` -[페이지 로드] - ↓ -PopDesigner useEffect - ↓ -screenApi.getLayoutPop(screenId) - ↓ -isV5Layout(data) 체크 - ↓ -setLayout(data) 또는 createEmptyPopLayoutV5() -``` - ---- - -## API 엔드포인트 - -| 메서드 | 경로 | 설명 | -|--------|------|------| -| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 | -| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 | - ---- - -## 삭제된 레거시 (참고용) - -| 파일 | 버전 | 이유 | -|------|------|------| -| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 | -| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 | -| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 | -| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 | - ---- - -*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)* diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md deleted file mode 100644 index a20e051f..00000000 --- a/popdocs/CHANGELOG.md +++ /dev/null @@ -1,1205 +0,0 @@ -# POP 변경 이력 - -형식: [Keep a Changelog](https://keepachangelog.com/) - ---- - -## [미출시] - -- Phase 4: 실제 컴포넌트 구현 (pop-field, pop-button 등) -- 데이터 바인딩 구현 -- 워크플로우 연동 - ---- - -## [2026-02-09] origin/main 병합 (ksh-v2-work-merge-test) - -### 배경 (왜 이 작업이 필요했는가) - -**상황**: -- `ksh-v2-work` 브랜치에서 POP 디자이너 개발이 진행되는 동안, `main` 브랜치에서 데스크톱 ScreenDesigner 관련 대규모 업데이트(`feature/v2-unified-renewal` PR #386)가 병합됨 -- 두 브랜치가 `ScreenDesigner.tsx`를 동시에 수정하여 병합 충돌 발생 -- 안전한 병합을 위해 `ksh-v2-work-merge-test` 테스트 브랜치에서 작업 - -**병합 통계**: -- 소스: `origin/main` (86 커밋) -- 대상: `ksh-v2-work-merge-test` (ksh-v2-work에서 분기, 14 커밋) -- 분기점: `3fca677f` (feat: V2Media 컴포넌트 추가) -- 변경 파일: 207개 (43,535줄 추가, 3,547줄 삭제) -- 충돌 파일: 1개 (`ScreenDesigner.tsx`) - -### Merged (origin/main에서 가져온 주요 변경) - -- **ScreenDesigner 데스크톱 기능 강화** - - 그룹 정렬/분배/크기 맞춤 (handleGroupAlign, handleGroupDistribute, handleMatchSize) - - 라벨 토글 (handleToggleAllLabels) - - 단축키 도움말 모달 (showShortcutsModal) - - 디버그 console.log 정리 - -- **백엔드 신규 기능** - - 스케줄 관리 API (scheduleController, scheduleRoutes, scheduleService) - - 파일 관리 개선 (fileController, fileRoutes) - - 테이블 관리 확장 (tableManagementController) - - 넘버링 규칙 개선 (numberingRuleController) - -- **프론트엔드 신규/수정** - - 레이어 매니저 패널, 레이어 조건 패널 - - 화면 복사 모달, 편집 모달 - - InteractiveDataTable, InteractiveScreenViewer - - 넘버링 규칙 디자이너 - - 화면 그룹 트리뷰, 화면 관계 플로우 - -### 충돌 해결 (ScreenDesigner.tsx) - -3건의 충돌을 수동 해결: - -| 충돌 | 영역 | 해결 방식 | -|------|------|----------| -| 1. 함수 시그니처 | `isPop`, `defaultDevicePreview` props 추가 | ksh-v2-work 유지 (POP 모드 지원), 중복 `usePanelState` 제거 | -| 2. 저장 로직 | POP/V2/Legacy 3단계 분기 | ksh-v2-work 유지 (3단계 분기), console.log 제거 | -| 3. 툴바 props | 정렬/분배/크기맞춤/라벨토글/단축키 | origin/main 채택 (데스크톱 신규 기능 모두 포함) | - -### 검증 결과 - -| 항목 | 결과 | -|------|------| -| 충돌 마커 잔존 | 없음 | -| TypeScript 컴파일 | 신규 에러 없음 (기존 에러만) | -| 프론트엔드 빌드 | 성공 | -| 백엔드 빌드 | 신규 에러 없음 (`docx`/`bwip-js` 기존 이슈만) | -| 시맨틱 충돌 | 없음 | -| 린트 | 기능 에러 없음 (들여쓰기 차이만) | - -### 주의사항 - -- **Conflict 3 영역 들여쓰기**: origin/main에서 가져온 툴바 JSX(L5745~6631)의 들여쓰기가 ksh-v2-work와 2칸 차이. 기능에는 영향 없으나, 추후 포매팅 정리 권장 -- **기존 타입 에러**: `GridSettings`/`GridUtilSettings` 불일치, `SCREEN_RESOLUTIONS` export type 문제 등은 병합 이전부터 존재하던 기술 부채 - -### 수정 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `ScreenDesigner.tsx` | 3건 충돌 수동 해결 (함수 시그니처, 저장 로직, 툴바 props) | -| 외 207개 파일 | origin/main에서 자동 병합 | - ---- - -## [2026-02-06] v5.2.1 그리드 셀 크기 강제 고정 - -### 배경 (왜 이 작업이 필요했는가) - -**문제 상황**: -- 4칸 모드에서 특정 행의 가이드 셀이 다른 행보다 작게 표시됨 -- `gridAutoRows`는 최소 높이만 보장하여, 컴포넌트 콘텐츠가 행 높이를 밀어내면 인접 빈 셀도 영향받음 -- "셀의 크기 = 컴포넌트의 크기"라는 핵심 설계 원칙이 시각적으로 깨짐 - -### Changed - -- **gridAutoRows → gridTemplateRows** (PopRenderer.tsx) - ```typescript - // 변경 전: 최소 높이만 보장 (콘텐츠에 따라 늘어남) - gridAutoRows: `${breakpoint.rowHeight}px` - - // 변경 후: 행 높이 강제 고정 - gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` - gridAutoRows: `${breakpoint.rowHeight}px` // 동적 추가행 대비 유지 - ``` - -- **dynamicRowCount 분리** (PopRenderer.tsx) - - gridCells 내부 → 독립 useMemo로 분리 - - gridStyle과 gridCells에서 공유 - -- **컴포넌트 overflow 변경** (PopRenderer.tsx) - - `overflow-visible` → `overflow-hidden` - - 컴포넌트 콘텐츠가 셀 경계를 벗어나지 않도록 강제 - -### Fixed - -- **PopRenderer dynamicRowCount에서 숨김 컴포넌트 포함 문제** - - PopCanvas는 숨김 제외하여 높이 계산, PopRenderer는 포함하여 계산 → 기준 불일치 - - PopRenderer에도 숨김 필터 추가, 여유행 +5 → +3으로 통일 - -- **디버깅 console.log 잔존** (PopCanvas.tsx) - - reviewComponents useMemo 내 console.log 2개 삭제 - -- **뷰어 viewportWidth 선언 순서** (page.tsx) - - currentModeKey보다 뒤에 선언되어 있던 viewportWidth를 앞으로 이동 - -### 수정 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `PopRenderer.tsx` | gridTemplateRows 강제 고정, dynamicRowCount 분리, overflow-hidden, 숨김 필터 추가 | -| `PopCanvas.tsx` | 디버깅 console.log 삭제 | -| `page.tsx (뷰어)` | viewportWidth 선언 순서 수정 | - ---- - -## [2026-02-06] v5.2 브레이크포인트 재설계 + 세로 자동 확장 - -### 배경 (왜 이 작업이 필요했는가) - -**문제 상황**: -- 뷰어에서 브라우저 수동 리사이즈 시 768~839px 구간에서 모드 불일치 -- useResponsiveMode 훅과 GRID_BREAKPOINTS 상수 간 기준 불일치 -- 기존 브레이크포인트가 실제 기기 뷰포트와 맞지 않음 - -**사용자 요구사항**: -- "현장 모바일 기기 8~14인치, 핸드폰은 아이폰 미니 ~ 갤럭시 울트라" -- "세로는 신경쓸 필요 없고 무한 스크롤 가능해야 함" - -### Changed - -- **브레이크포인트 재설계** (pop-layout.ts) - | 모드 | 변경 전 | 변경 후 | 근거 | - |------|--------|--------|------| - | mobile_portrait | ~599px | ~479px | 스마트폰 세로 최대 440px | - | mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 | - | tablet_portrait | 840~1023px | 768~1023px | iPad Mini 768px 포함 | - | tablet_landscape | 1024px+ | 동일 | - | - -- **detectGridMode() 조건 수정** (pop-layout.ts) - ```typescript - if (viewportWidth < 480) return "mobile_portrait"; // was 600 - if (viewportWidth < 768) return "mobile_landscape"; // was 840 - if (viewportWidth < 1024) return "tablet_portrait"; - ``` - -- **BREAKPOINTS.TABLET_MIN 변경** (useDeviceOrientation.ts) - - 768 (was 840) - -- **VIEWPORT_PRESETS에서 height 제거** (PopCanvas.tsx) - - width만 유지, 세로는 무한 스크롤 - -### Added - -- **세로 자동 확장** (PopCanvas.tsx) - - `MIN_CANVAS_HEIGHT = 600`: 최소 캔버스 높이 - - `CANVAS_EXTRA_ROWS = 3`: 항상 유지되는 여유 행 수 - - `dynamicCanvasHeight`: 컴포넌트 배치 기반 동적 계산 - -- **격자 셀 동적 계산** (PopRenderer.tsx) - - 고정 20행 → maxRowEnd + 5 동적 계산 - -- **뷰어 일관성 확보** (page.tsx) - - 프리뷰 모드: useResponsiveModeWithOverride 유지 - - 일반 모드: detectGridMode(viewportWidth) 직접 사용 - -### Fixed - -- **뷰어 반응형 모드 불일치** - - 768~839px 구간에서 6칸/8칸 모드 불일치 해결 - -- **hiddenComponentIds 중복 정의 에러** - - 라인 410-412 중복 useMemo 제거 - -### Technical Details - -``` -브레이크포인트 재설계 근거 (실제 기기 CSS 뷰포트): - -| 기기 | CSS 뷰포트 너비 | -|------|----------------| -| iPhone SE | 375px | -| iPhone 16 Pro | 402px | -| Galaxy S25 Ultra | 440px | -| iPad Mini 7 | 768px | -| iPad Pro 11 | 834px (세로), 1194px (가로) | -| iPad Pro 13 | 1024px (세로), 1366px (가로) | - -→ 768px, 1024px가 업계 표준 (Tailwind, Bootstrap 동일) -``` - -``` -세로 자동 확장 로직: - -const dynamicCanvasHeight = useMemo(() => { - const maxRowEnd = visibleComps.reduce((max, comp) => { - return Math.max(max, comp.row + comp.rowSpan); - }, 1); - - const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS; // +3행 여유 - const height = totalRows * (rowHeight + gap) + padding * 2; - - return Math.max(MIN_CANVAS_HEIGHT, height); // 최소 600px -}, [...]); -``` - -### 수정 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `pop-layout.ts` | GRID_BREAKPOINTS 값 수정, detectGridMode() 조건 수정 | -| `useDeviceOrientation.ts` | BREAKPOINTS.TABLET_MIN = 768 | -| `PopCanvas.tsx` | VIEWPORT_PRESETS height 제거, dynamicCanvasHeight 추가 | -| `PopRenderer.tsx` | gridCells 동적 행 수 계산 | -| `page.tsx (뷰어)` | detectGridMode() 사용 | - ---- - -## [2026-02-06] v5.1 자동 줄바꿈 + 검토 필요 시스템 - -### 배경 (왜 이 작업이 필요했는가) - -**문제 상황**: -- 12칸에서 배치한 컴포넌트가 4칸 모드로 전환하면 "화면 밖" 패널로 이동하여 뷰어에서 안 보임 -- 사용자가 모든 모드를 수동으로 편집해야 하는 부담 -- "화면 밖" 개념이 실제로는 "검토 필요" 알림 역할이었음 - -**해결 방향**: -- 자동 줄바꿈: col > maxCol인 컴포넌트를 자동으로 맨 아래에 배치 -- 정보 손실 방지: 모든 컴포넌트가 항상 그리드 안에 표시됨 -- 검토 필요 알림: 오버라이드 없으면 "검토 필요" 표시 (자동 배치 상태) - -### Added - -- **자동 줄바꿈 로직** (gridUtils.ts) - - `convertAndResolvePositions()` 수정 - - 원본 col 보존 로직 추가 - - 정상 컴포넌트 vs 초과 컴포넌트 분리 - - 초과 컴포넌트를 맨 아래에 순차 배치 (col=1, row=맨아래+1) - - colSpan 자동 축소 (targetColumns 초과 방지) - -- **검토 필요 판별 함수** (gridUtils.ts) - - `needsReview()` 신규 함수 - - 기준: 12칸 아니고 + 오버라이드 없으면 → 검토 필요 - - 간단한 로직: "이 모드에서 편집했냐 안 했냐" - -- **검토 필요 패널** (PopCanvas.tsx) - - `ReviewPanel`: "화면 밖" → "검토 필요"로 이름 변경 - - `ReviewItem`: 클릭 시 해당 컴포넌트 선택 (드래그 없음) - - 자동 배치 뱃지 표시 - - 파란색 테마 (경고 아닌 안내 느낌) - -### Changed - -- **isOutOfBounds() Deprecated** (gridUtils.ts) - - `@deprecated` 주석 추가 - - needsReview()로 대체 권장 - - 하위 호환을 위해 함수는 유지 - -- **"화면 밖" 패널 역할 변경** (PopCanvas.tsx) - - 기존: col > maxCol → 화면 밖 (드래그로 복원) - - 변경: 오버라이드 없음 → 검토 필요 (클릭으로 선택) - - 숨김 기능과 완전히 분리 (별도 유지) - -### Fixed - -- **정보 손실 문제 해결** - - 모든 컴포넌트가 항상 그리드 안에 배치됨 - - 뷰어에서도 자동 배치가 적용되어 모두 표시됨 - -### Technical Details - -``` -자동 줄바꿈 로직: - -1. convertAndResolvePositions() 호출 - components: [ {id: "A", position: {col:1, ...}}, {id: "B", position: {col:5, ...}} ] - targetMode: "mobile_portrait" (4칸) - -2. 비율 변환 + 원본 col 보존 - converted: [ - {id: "A", position: {col:1, ...}, originalCol: 1}, - {id: "B", position: {col:2, ...}, originalCol: 5} // col은 변환됨, 원본은 5 - ] - -3. 정상 vs 초과 분리 - normalComponents: [A] // originalCol ≤ 4 - overflowComponents: [B] // originalCol > 4 - -4. 맨 아래 배치 - maxRow = A의 (row + rowSpan - 1) = 1 - B: col=1, row=2 (맨 아래에 자동 배치) - -5. 겹침 해결 - resolveOverlaps([A, B], 4) // 최종 위치 확정 - -6. 검토 필요 판별 - needsReview("mobile_portrait", false) // 오버라이드 없음 → true - → ReviewPanel에 B 표시 -``` - -``` -검토 필요 vs 숨김: - -구분 | 검토 필요 | 숨김 -------------- | ---------------------- | ------------------- -역할 | 자동 배치 알림 | 의도적 숨김 -뷰어에서 | 보임 (자동 배치) | 안 보임 -디자이너에서 | ReviewPanel 표시 | HiddenPanel 표시 -판단 기준 | 오버라이드 없음 | hidden 배열에 ID -색상 테마 | 파란색 (안내) | 회색 (제외) -``` - -### 수정 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `gridUtils.ts` | convertAndResolvePositions 자동 줄바꿈, needsReview 추가, isOutOfBounds deprecated | -| `PopCanvas.tsx` | OutOfBoundsPanel → ReviewPanel 변경, needsReview 필터링 | -| `PopRenderer.tsx` | isOutOfBounds import 제거 (사용 안 함) | -| `README.md` | v5.1 버전 표시, 최신 기능 요약 | -| `CHANGELOG.md` | v5.1 항목 추가 | - ---- - -## [2026-02-05 심야] 반응형 레이아웃 + 숨김 기능 완성 - -### 배경 (왜 이 작업이 필요했는가) - -**문제 상황**: -- 12칸 모드에서 배치한 컴포넌트가 4칸 모드에서 초과됨 -- 모드별로 컴포넌트 위치/크기를 다르게 설정할 방법 없음 -- 특정 모드에서만 컴포넌트를 숨길 방법 없음 - -**해결 방향**: -- 모드별 오버라이드 시스템으로 위치/크기 개별 저장 -- 화면 밖 컴포넌트를 별도 패널에 표시하고 드래그로 재배치 -- 숨김 기능으로 특정 모드에서 컴포넌트 제외 - -### Added - -- **모드별 오버라이드 시스템** (PopDesigner.tsx, pop-layout.ts) - - `PopModeOverrideV5.positions`: 모드별 컴포넌트 위치 저장 - - `PopModeOverrideV5.hidden`: 모드별 숨김 컴포넌트 ID 배열 - - `getEffectiveComponentPosition()`: 오버라이드된 위치 반환 - - 드래그/리사이즈 시 자동으로 오버라이드 저장 - -- **화면 밖 컴포넌트 패널** (PopCanvas.tsx) - - `OutOfBoundsPanel`: 현재 모드에서 초과하는 컴포넌트 표시 - - `OutOfBoundsItem`: 드래그 가능한 회색 컴포넌트 카드 - - `isOutOfBounds()`: 컴포넌트가 현재 모드 칸 수 초과 여부 판단 - - 클릭하면 숨김 패널로 이동 - -- **숨김 기능** (PopDesigner.tsx, PopCanvas.tsx) - - `HiddenPanel`: 숨김 처리된 컴포넌트 표시 - - `HiddenItem`: 드래그로 숨김 해제 가능 - - `handleHideComponent()`: 컴포넌트 숨김 처리 - - `handleUnhideComponent()`: 숨김 해제 (handleMoveComponent에 통합) - - 숨김 방법 3가지: - 1. 그리드 → 숨김패널 드래그 - 2. H키 단축키 - 3. 화면밖 컴포넌트 클릭 - -- **리사이즈 겹침 검사** (PopRenderer.tsx) - - `checkResizeOverlap()`: 리사이즈 시 다른 컴포넌트와 겹침 검사 - - 겹치면 리사이즈 취소 및 toast 알림 - -- **원본으로 되돌리기** (PopDesigner.tsx) - - `handleResetToDefault()`: 현재 모드 오버라이드 삭제 - - 자동 위치 계산으로 복원 - -### Fixed - -- **숨김 컴포넌트 드래그 안됨 버그** - - 원인: `onUnhideComponent`와 `onMoveComponent`가 별도로 호출되어 상태 충돌 - - 해결: `handleMoveComponent`에서 숨김 해제 로직 통합 (단일 상태 업데이트) - -- **그리드 범위 초과 에러** - - 원인: 드롭 위치 + colSpan이 칸 수 초과 - - 해결: 드롭 시 `adjustedCol` 계산하여 자동으로 왼쪽으로 밀어서 배치 - -- **getAllEffectivePositions에 숨김 컴포넌트 포함** - - 해결: 숨김 및 화면밖 컴포넌트를 결과에서 제외 - -- **Expected drag drop context 에러 (뷰어 페이지)** - - 원인: `DraggableComponent`에서 `useDrag` 훅이 `DndProvider` 없이 호출됨 - - 해결: `isDesignMode=false`일 때 `DraggableComponent` 대신 일반 `div`로 렌더링 - -### Changed - -- **PopModeOverrideV5 타입 확장** - ```typescript - interface PopModeOverrideV5 { - positions?: Record>; // 위치 오버라이드 - hidden?: string[]; // 숨김 컴포넌트 ID 배열 - } - ``` - -- **12칸 모드(tablet_landscape) 제한** - - 기본 모드이므로 숨김 기능 비활성화 - - 화면밖 패널 표시 안함 - - 위치 변경은 기본 position에 직접 저장 - -- **패널 레이아웃 재구성** (PopCanvas.tsx) - - 오른쪽에 화면밖 패널 + 숨김 패널 세로 배치 - - 12칸 모드에서는 패널 숨김 - -### Technical Details - -``` -오버라이드 데이터 흐름: - -1. 컴포넌트 드래그/리사이즈 - ↓ -2. currentMode 확인 - ↓ -3-a. tablet_landscape → layout.components[id].position 직접 수정 -3-b. 다른 모드 → layout.overrides[mode].positions[id]에 저장 - ↓ -4. getEffectiveComponentPosition()이 우선순위대로 반환 - 우선순위: overrides > autoResolved > 기본 position - -숨김 기능 흐름: - -1. 숨김 요청 (드래그/H키/클릭) - ↓ -2. layout.overrides[mode].hidden 배열에 ID 추가 - ↓ -3. PopRenderer에서 hidden 체크 → 렌더링 제외 - ↓ -4. HiddenPanel에서 표시 - ↓ -5. 드래그로 그리드에 복원 → hidden 배열에서 제거 + 위치 업데이트 (단일 상태 업데이트) -``` - -### 수정 파일 -| 파일 | 변경 내용 | -|------|----------| -| `pop-layout.ts` | PopModeOverrideV5.hidden 추가 | -| `PopDesigner.tsx` | handleHideComponent, handleUnhideComponent 통합, 오버라이드 저장 | -| `PopCanvas.tsx` | OutOfBoundsPanel, HiddenPanel 추가, 드롭 위치 자동 조정 | -| `PopRenderer.tsx` | 숨김 필터링, 리사이즈 겹침 검사 | -| `gridUtils.ts` | getAllEffectivePositions에서 숨김/화면밖 제외, isOutOfBounds 함수 | - ---- - -## [2026-02-05 저녁] 드래그앤드롭 완전 수정 - -### 배경 (왜 좌표 계산이 틀렸는가) - -**문제 상황**: -- 컴포넌트를 아래로 드래그해도 위로 올라감 -- Row 92 같은 비정상적인 좌표로 배치됨 -- 드래그 이동/리사이즈가 전혀 작동하지 않음 - -**핵심 원인**: 캔버스에 `transform: scale(0.8)` 적용 시 좌표 계산 불일치 -``` -문제: -- getBoundingClientRect() → 스케일 적용된 크기 반환 (예: 1024px → 819px) -- getClientOffset() → 뷰포트 기준 실제 마우스 좌표 -- 이 둘을 그대로 계산하면 좌표가 완전히 틀림 -``` - -**해결**: 단순한 상대 좌표 + 스케일 보정 -```typescript -// 캔버스 내 상대 좌표 (스케일 보정) -const relX = (마우스X - 캔버스left) / canvasScale; -const relY = (마우스Y - 캔버스top) / canvasScale; -calcGridPosition(relX, relY, customWidth, ...); // 실제 캔버스 크기 사용 -``` - -### Added -- **`calcGridPosition()` 함수** (PopCanvas.tsx) - - 캔버스 내 상대 좌표를 그리드 좌표로 변환 - - 패딩, gap, 셀 너비를 고려한 정확한 계산 - -- **공통 DND 상수** (constants/dnd.ts) - - `DND_ITEM_TYPES.COMPONENT`: 팔레트에서 새 컴포넌트 - - `DND_ITEM_TYPES.MOVE_COMPONENT`: 기존 컴포넌트 이동 - - 3개 파일에서 중복 정의되던 것을 통합 - -### Fixed -- **스케일 보정 누락** - - 캔버스 줌(scale)이 적용된 상태에서 좌표 계산 오류 - - `(offset - rect.left) / scale`로 보정 - -- **DND 타입 상수 불일치** - - PopCanvas: `"component"`, `"MOVE_COMPONENT"` - - PopRenderer: `"MOVE_COMPONENT"` (하드코딩) - - ComponentPalette: `"component"` (로컬 정의) - - 모두 공통 상수로 통합 - -- **컴포넌트 중첩(겹침) 문제** - - 원인: `toast` import 누락으로 겹침 감지 로직이 실행 안됨 - - 해결: `sonner`에서 toast import 추가 - - 겹침 시 `findNextEmptyPosition()`으로 자동 재배치 - -- **리사이즈 핸들 작동 안됨** - - 원인: `useDrop` 훅 2개가 같은 `canvasRef`에 중복 적용 - - 해결: 단일 `useDrop`으로 통합 (`COMPONENT` + `MOVE_COMPONENT` 모두 처리) - -- **불필요한 toast 메시지 제거** - - "컴포넌트가 이동되었습니다" 알림 삭제 - -### Changed -- **mouseToGridPosition 단순화** - - 복잡한 DOMRect 전달 대신 필요한 값만 직접 전달 - - gridUtils.ts의 함수는 유지 (다른 곳에서 사용) - -### Technical Details -``` -좌표 변환 흐름 (수정 후): - -1. 마우스 드롭 - offset = monitor.getClientOffset() // 뷰포트 기준 {x: 500, y: 300} - -2. 캔버스 위치 - canvasRect = canvasRef.getBoundingClientRect() // {left: 250, top: 100} - -3. 스케일 보정된 상대 좌표 - relX = (500 - 250) / 0.8 = 312.5 // 캔버스 내 실제 X - relY = (300 - 100) / 0.8 = 250 // 캔버스 내 실제 Y - -4. 그리드 좌표 계산 - calcGridPosition(312.5, 250, 1024, 12, 48, 16, 24) - → { col: 5, row: 4 } -``` - -### 수정 파일 -| 파일 | 변경 내용 | -|------|----------| -| `PopCanvas.tsx` | calcGridPosition 추가, 스케일 보정 적용 | -| `PopDesigner.tsx` | toast 메시지 제거 | -| `PopRenderer.tsx` | DND 상수 import | -| `ComponentPalette.tsx` | DND 상수 import | -| `constants/dnd.ts` | 새 파일 (DND 타입 상수) | -| `constants/index.ts` | 새 파일 (export) | - ---- - -## [2026-02-05 오후] 그리드 가이드 CSS Grid 통합 - -### 배경 (왜 재설계했는가) - -**문제 상황**: -- GridGuide.tsx(SVG 기반)와 PopRenderer.tsx(CSS Grid)가 좌표계 불일치 -- 격자선과 컴포넌트가 정렬되지 않음 ("무늬가 따로 논다") -- 행/열 라벨이 4부터 시작하는 등 오류 - -**핵심 원칙**: -> "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다" - -**결정**: SVG 격자 삭제, CSS Grid 기반 통합 -→ 상세: [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) - -### Breaking Changes -- `GridGuide.tsx` 삭제 (SVG 기반 격자) - -### Added -- **CSS Grid 기반 격자 셀** (PopRenderer.tsx) - - `gridCells`: 12x20 = 240개 실제 DOM 셀 - - `border-dashed border-blue-300/40` 스타일 - - 컴포넌트는 `z-index:10`으로 위에 표시 - - `showGridGuide` prop으로 ON/OFF - -- **행/열 라벨** (PopCanvas.tsx) - - 열 라벨: 1~12 (캔버스 상단) - - 행 라벨: 1~20 (캔버스 좌측) - - absolute positioning으로 정확한 정렬 - - 줌/패닝에 연동 - -- **그리드 토글 버튼** (PopCanvas.tsx) - - "그리드 ON/OFF" 버튼 추가 - - 격자 표시 상태 관리 - -### Changed -- **컴포넌트 타입 단순화** - - `PopComponentType`: `pop-sample` 1개로 단순화 - - `DEFAULT_COMPONENT_GRID_SIZE`: `pop-sample` 전용 - - `ComponentPalette.tsx`: 샘플 박스 1개만 표시 - - `PopRenderer.tsx`: 샘플 박스 렌더링으로 단순화 - -### Technical Details -``` -역할 분담: -- PopRenderer: 격자 셀(div) + 컴포넌트 (같은 CSS Grid 좌표계) -- PopCanvas: 라벨 + 줌/패닝 + 토글 -- GridGuide: 삭제 - -격자 셀 구조: -┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ -│1,1│2,1│3,1│4,1│5,1│6,1│7,1│8,1│9,1│10│11│12 │ ← col -├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤ -│1,2│... │ -└───┴───────────────────────────────────────────┘ - ↑ row -``` - ---- - -## [2026-02-05] v5 그리드 시스템 완전 통합 - -### 배경 (왜 v5로 전환했는가) - -**문제 상황**: -- v4 Flexbox로 반응형 구현 시도 → 배치가 예측 불가능 -- 캔버스에 "그리듯이" 배치하면 화면 크기별로 깨짐 - -**상급자 피드백**: -> "이런 식이면 나중에 문제가 생긴다." -> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라. -> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에 -> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라." - -**연구 내용**: -- Softr: 블록 기반, 제약 기반 레이아웃 -- Ant Design: 24열 그리드, 8px 간격 -- Material Design: 4/8/12열, 반응형 브레이크포인트 - -**결정**: CSS Grid 기반 그리드 시스템 (v5) 채택 -→ 상세: [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) - -### popdocs 문서 구조 재정비 - -**배경**: 문서가 AI 에이전트 진입점 역할을 못함, 컨텍스트 효율화 필요 - -**적용 기법**: Progressive Disclosure (점진적 공개), Token as Currency - -**추가된 파일**: -- `SAVE_RULES.md`: AI 저장/조회 규칙, 템플릿 -- `STATUS.md`: 현재 진행 상태, 중단점 -- `PROBLEMS.md`: 문제-해결 색인 -- `INDEX.md`: 기능별 색인 -- `sessions/`: 날짜별 작업 기록 - -**문서 계층**: -- Layer 1 (진입점): README, STATUS, SAVE_RULES -- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE -- Layer 3 (심화): decisions/, sessions/, archive/ - -### Breaking Changes -- **v1, v2, v3, v4 레이아웃 완전 삭제** -- 기존 POP 화면 데이터 전체 초기화 필요 -- 레거시 컴포넌트 및 타입 삭제 - -### Added -- **CSS Grid 기반 그리드 시스템 (v5)** - - 4개 모드별 칸 수: 4/6/8/12칸 - - 명시적 위치 지정 (col, row, colSpan, rowSpan) - - 모드별 오버라이드 지원 - - 자동 위치 변환 (12칸 기준 → 다른 모드) - -- **통합된 파일 구조** - - `PopCanvas.tsx`: 그리드 캔버스 (DnD + 줌 + 모드 전환) - - `PopRenderer.tsx`: 그리드 렌더링 - - `ComponentEditorPanel.tsx`: 속성 편집 - - `pop-layout.ts`: v5 전용 타입 정의 - - `gridUtils.ts`: 그리드 유틸리티 함수 - -### Removed -- `PopCanvasV4.tsx`, `PopCanvas.tsx (v3)` -- `PopFlexRenderer.tsx`, `PopLayoutRenderer.tsx` -- `ComponentEditorPanelV4.tsx`, `PopPanel.tsx` -- v1, v2, v3, v4 타입 정의 및 유틸리티 함수 -- `test-v4` 테스트 페이지 - -### Changed -- `screenManagementService.ts`: v5 전용으로 단순화 -- `screen_layouts_pop` 테이블: 기존 데이터 삭제, v5 전용 -- `PopDesigner.tsx`: v5 전용으로 리팩토링 -- 뷰어 페이지: v5 렌더러 전용 - -### Technical Details -```typescript -// v5 그리드 모드 -type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - -// 그리드 설정 -const GRID_BREAKPOINTS = { - mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 }, - mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 }, - tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 }, - tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 }, -}; - -// 컴포넌트 위치 -interface PopGridPosition { - col: number; // 시작 열 (1부터) - row: number; // 시작 행 (1부터) - colSpan: number; // 열 크기 - rowSpan: number; // 행 크기 -} -``` - ---- - -## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능 - -### Added -- **현재 모드 추적** (PopDesigner.tsx) - - `currentViewportMode` 상태 추가 - - PopCanvasV4와 양방향 동기화 - - 모드 변경 시 자동 업데이트 - -- **배치 고정 기능** - - "고정" 버튼 추가 (기본 모드 제외) - - `handleLockLayoutV4()` - 현재 배치를 오버라이드에 저장 - - 배치 정보: direction, wrap, gap, alignItems, justifyContent, children 순서 - -- **오버라이드 초기화 기능** - - `handleResetOverrideV4()` - 오버라이드 삭제 - - "자동으로 되돌리기" 버튼 (편집된 모드만 표시) - - 자동 계산으로 되돌림 - -### Changed -- **PopCanvasV4 Props 구조 변경** - - `currentMode` prop 추가 (외부에서 제어) - - `onModeChange` 콜백 추가 - - `onLockLayout` 콜백 추가 - - 내부 `activeViewport` 상태 제거 (부모가 관리) - -- **프리셋 버튼 동작** - - 클릭 시 부모 상태 업데이트 (`onModeChange`) - - `currentMode` prop 기반으로 활성 상태 표시 - -### Technical Details -```typescript -// 고정 로직 -const handleLockLayoutV4 = () => { - const newLayout = { - ...layoutV4, - overrides: { - ...layoutV4.overrides, - [currentViewportMode]: { - containers: { - root: { - direction: layoutV4.root.direction, - wrap: layoutV4.root.wrap, - gap: layoutV4.root.gap, - children: layoutV4.root.children, // 순서 고정 - // ... 기타 배치 속성 - } - } - } - } - }; -}; - -// 초기화 로직 -const handleResetOverrideV4 = (mode) => { - const newOverrides = { ...layoutV4.overrides }; - delete newOverrides[mode]; - // overrides가 비면 undefined로 설정 -}; -``` - -### UI 변경 -``` -툴바: -[모바일↕] [모바일↔] [태블릿↕] [태블릿↔(기본)] [고정] [자동으로 되돌리기] - -조건부 표시: -- "고정" 버튼: 기본 모드가 아닐 때 -- "자동으로 되돌리기": 오버라이드가 있을 때 -``` - -### 주의사항 -- 크기는 고정하지 않음 (여전히 자동 스케일링) -- 배치만 오버라이드 (순서, 방향, 정렬) -- 최소/최대값 기능은 별도 구현 필요 - ---- - -## [2026-02-04] Phase 2 시작 - 오버라이드 UI 표시 - -### Added -- **오버라이드 데이터 구조** (pop-layout.ts) - - `PopModeOverride` 인터페이스 추가 - - `PopLayoutDataV4.overrides` 필드 추가 - - 3개 모드 오버라이드 지원 (mobile_portrait, mobile_landscape, tablet_portrait) - -- **프리셋 버튼 상태 표시** (PopCanvasV4.tsx) - - 기본 모드: "(기본)" 텍스트 표시 - - 편집된 모드: "(편집)" 텍스트 + 노란색 강조 - - 자동 모드: 기본 스타일 - -### Changed -- **hasOverride 함수 구현** - - `layout.overrides` 필드 체크 - - 컴포넌트/컨테이너 오버라이드 존재 여부 확인 - ---- - -## [2026-02-04] 비율 스케일링 시스템 구현 - -### Added -- **비율 스케일링 시스템** (업계 표준 Scale with Fixed Aspect Ratio) - - 기준 너비: 1024px (10인치 태블릿 가로) - - 최대 너비: 1366px (12인치 태블릿) - - 8~12인치 화면에서 배치 유지, 크기만 비례 조정 - -- **뷰포트 감지** (page.tsx) - - `viewportWidth` state 추가 - - resize 이벤트 리스너로 실시간 감지 - - `Math.min(window.innerWidth, 1366)` 최대값 제한 - -### Changed -- **PopFlexRenderer.tsx** - - `BASE_VIEWPORT_WIDTH = 1024` 상수 추가 - - `scale = viewportWidth / BASE_VIEWPORT_WIDTH` 계산 - - `calculateSizeStyle()` 함수에 scale 파라미터 추가 - - 컴포넌트 크기 (fixedWidth, fixedHeight) 스케일 적용 - - 컨테이너 gap, padding 스케일 적용 - - 디자인 모드: scale = 1 (원본), 뷰어 모드: 실제 scale 적용 - -- **ComponentRendererV4** - - `viewportWidth` prop 추가 - - 내부에서 scale 계산하여 sizeStyle에 적용 - -- **ContainerRenderer** - - scaledGap, scaledPadding 계산하여 containerStyle에 적용 - -### Technical Details -``` -비율 스케일링 계산: -scale = 실제 화면 너비 / 기준 너비 (1024px) - -예시: -- 800px (8인치): scale = 0.78 → 200px 컴포넌트 → 156px -- 1024px (10인치): scale = 1.00 → 200px 컴포넌트 → 200px (기준) -- 1366px (12인치): scale = 1.33 → 200px 컴포넌트 → 266px -- 1920px (데스크톱): max-width 1366px 적용 → 12인치와 동일 + 여백 -``` - -### Fixed -- **DndProvider 에러** (뷰어 페이지) - - 원인: isDesignMode=false일 때 useDrag/useDrop 훅 호출 - - 해결: DraggableComponentWrapper에서 isDesignMode 체크 후 early return - ---- - -## [2026-02-04] Flexbox 가로 배치 + Spacer + Undo/Redo 개선 - -### Added -- **Spacer 컴포넌트** (`pop-spacer`) - - 빈 공간을 차지하여 레이아웃 정렬에 사용 - - 기본 크기: `width: fill`, `height: 48px` - - 디자인 모드에서 점선 배경으로 표시 - - 실제 모드에서는 투명 (공간만 차지) - -- **컴포넌트 순서 변경 (드래그 앤 드롭)** - - 같은 컨테이너 내에서 컴포넌트 순서 변경 가능 - - 드래그 중인 컴포넌트는 반투명하게 표시 - - 드롭 위치는 파란색 테두리로 표시 - - `handleReorderComponentV4` 핸들러 추가 - -### Changed -- **기본 레이아웃 방향 변경** (Flexbox 가로 배치) - - `direction: "vertical"` → `direction: "horizontal"` - - `wrap: false` → `wrap: true` (자동 줄바꿈) - - `alignItems: "stretch"` → `alignItems: "start"` - - 컴포넌트가 가로로 나열되고, 공간 부족 시 다음 줄로 이동 - -- **컴포넌트 기본 크기 타입별 설정** - - 필드: 200x48px (fixed) - - 버튼: 120x48px (fixed) - - 리스트: fill x 200px - - 인디케이터: 120x80px (fixed) - - 스캐너: 200x48px (fixed) - - 숫자패드: 200x280px (fixed) - - Spacer: fill x 48px - -- **Undo/Redo 방식 개선** (데스크탑 모드와 동일) - - `useLayoutHistory` 훅 제거 - - 별도 `history[]`, `historyIndex` 상태로 관리 - - `saveToHistoryV4()` 함수로 명시적 히스토리 저장 - - 컴포넌트 추가/삭제/수정/순서변경 시 히스토리 저장 - -- **디바이스 스크린 스크롤** - - `overflow: auto` 추가 (컴포넌트가 넘치면 스크롤) - - `height` → `minHeight` 변경 (컨텐츠에 따라 높이 증가) - -### Technical Details -``` -업계 표준 레이아웃 방식 (Figma, Webflow, FlutterFlow): -1. Flexbox 기반 Row/Column 배치 -2. 크기 제어: Fill / Fixed / Hug -3. Spacer 컴포넌트로 정렬 조정 -4. 화면 크기별 조건 분기 (반응형) - -사용 예시: -[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로 -[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로 -``` - ---- - -## [2026-02-04] 드래그 리사이즈 + Undo/Redo 기능 - -### Added -- **useLayoutHistory.ts** - Undo/Redo 히스토리 훅 - - 최대 50개 히스토리 저장 - - `undo()`, `redo()`, `canUndo`, `canRedo` - - `reset()` - 새 레이아웃 로드 시 히스토리 초기화 - -- **드래그 리사이즈 핸들** (PopFlexRenderer) - - 오른쪽 핸들: 너비 조정 (cursor: ew-resize) - - 아래쪽 핸들: 높이 조정 (cursor: ns-resize) - - 오른쪽 아래 핸들: 너비+높이 동시 조정 (cursor: nwse-resize) - - 선택된 컴포넌트에만 표시 - - 최소 크기 보장 (너비 48px, 높이 touchTargetMin) - -### Changed -- **PopDesigner.tsx** - - `useLayoutHistory` 훅 통합 (v3, v4 각각 독립적) - - Undo/Redo 버튼 추가 (툴바 오른쪽) - - 단축키 등록: - - `Ctrl+Z` / `Cmd+Z`: 실행 취소 - - `Ctrl+Shift+Z` / `Cmd+Shift+Z` / `Ctrl+Y`: 다시 실행 - - `handleResizeComponentV4` 핸들러 추가 - -- **PopCanvasV4.tsx** - - `onResizeComponent` prop 추가 - - PopFlexRenderer에 전달 - -- **PopFlexRenderer.tsx** - - `onComponentResize` prop 추가 - - ComponentRendererV4에 리사이즈 핸들 추가 - - 드래그 이벤트 처리 (mousemove, mouseup) - -### 단축키 목록 -| 단축키 | 기능 | -|--------|------| -| `Delete` / `Backspace` | 선택된 컴포넌트 삭제 | -| `Ctrl+Z` / `Cmd+Z` | 실행 취소 (Undo) | -| `Ctrl+Shift+Z` / `Ctrl+Y` | 다시 실행 (Redo) | -| `Space` + 드래그 | 캔버스 패닝 | -| `Ctrl` + 휠 | 줌 인/아웃 | - ---- - -## [2026-02-04] v4 통합 설계 모드 Phase 1 완료 - -### 목표 -v4를 기본 레이아웃 모드로 통합하고, 새 화면은 자동으로 v4로 시작 - -### Added -- **ComponentPaletteV4.tsx** - v4 전용 컴포넌트 팔레트 - - 6개 컴포넌트 (필드, 버튼, 리스트, 인디케이터, 스캐너, 숫자패드) - - 드래그 앤 드롭 지원 - -### Changed -- **PopDesigner.tsx** - v3/v4 통합 디자이너로 리팩토링 - - v3/v4 탭 제거 (자동 판별) - - 새 화면 → v4로 시작 - - 기존 v3 화면 → v3로 로드 (하위 호환) - - 빈 레이아웃 → v4로 시작 (컴포넌트 유무로 판별) - - 레이아웃 버전 텍스트 표시 ("자동 레이아웃 (v4)" / "4모드 레이아웃 (v3)") - -- **PopCanvasV4.tsx** - 4개 프리셋으로 변경 - - 기존: [모바일] [태블릿] [데스크톱] - - 변경: [모바일↕] [모바일↔] [태블릿↕] [태블릿↔] - - 기본 프리셋: 태블릿 가로 (1024x768) - - 슬라이더 범위: 320~1200px - - 비율 유지: 슬라이더 조절 시 높이도 비율에 맞게 자동 조정 - -### Fixed -- 새 화면이 v3로 열리는 문제 - - 원인: 백엔드가 빈 v2 레이아웃 반환 (version 필드 있음) - - 해결: 컴포넌트 유무로 빈 레이아웃 판별 → v4로 시작 - -### Technical Details -``` -레이아웃 로드 로직: -1. version 필드 확인 -2. components 존재 여부 확인 -3. version 있고 components 있음 → 해당 버전으로 로드 -4. version 없거나 components 없음 → v4로 새로 시작 -``` - ---- - -## [2026-02-04] Phase 2.1 완료 - 배치 고정 기능 (버그 수정) - -### 🔥 주요 버그 수정 -- **layoutV4.root 오염 문제 해결**: 다른 모드에서 편집 시 기본 레이아웃이 변경되던 버그 수정 -- **tempLayout 도입**: 고정 전 임시 배치를 별도 상태로 관리하여 root를 보호 -- **렌더러 병합 로직**: `PopFlexRenderer`에 오버라이드 자동 병합 기능 추가 - -### 데이터 흐름 개선 -1. **기본 모드 (태블릿 가로)** - - 드래그/속성 변경 → `layoutV4.root` 직접 수정 ✅ - - 모든 다른 모드의 기본값으로 사용 - -2. **다른 모드 (모바일 세로 등)** - - 드래그 → `tempLayout` 임시 저장 (화면에만 표시) - - "고정" 버튼 → `layoutV4.overrides[mode]`에 저장 - - 속성 패널 → 비활성화 + 안내 메시지 - -3. **렌더링** - - `tempLayout` 있으면 최우선 표시 (고정 전 미리보기) - - 오버라이드 있으면 `root`와 병합 - - 없으면 `root` 그대로 표시 - -### 수정 파일 -- `PopDesigner.tsx`: tempLayout 상태 추가, 핸들러 수정 -- `PopFlexRenderer.tsx`: 병합 로직 추가 (getMergedRoot) -- `PopCanvasV4.tsx`: tempLayout props 전달 -- `ComponentEditorPanelV4.tsx`: 속성 패널 비활성화 로직 - ---- - -## [2026-02-04] Phase 3 완료 - visibility + 줄바꿈 컴포넌트 - -### 추가 기능 -- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어 -- **pop-break 컴포넌트**: 강제 줄바꿈 (flex-basis: 100%) -- **컴포넌트 오버라이드 병합**: 모드별 컴포넌트 설정 변경 가능 - -### 타입 정의 -```typescript -interface PopComponentDefinitionV4 { - // 기존 속성... - - // 🆕 모드별 표시/숨김 - visibility?: { - tablet_landscape?: boolean; - tablet_portrait?: boolean; - mobile_landscape?: boolean; - mobile_portrait?: boolean; - }; -} - -// 🆕 줄바꿈 컴포넌트 -type PopComponentType = - | "pop-field" - | "pop-button" - | "pop-list" - | "pop-indicator" - | "pop-scanner" - | "pop-numpad" - | "pop-spacer" - | "pop-break"; // 새로 추가 -``` - -### 렌더러 개선 -- `isComponentVisible()`: visibility 체크 로직 -- `getMergedComponent()`: 컴포넌트 오버라이드 병합 -- pop-break 전용 렌더링 (디자인 모드: 점선, 실제: 높이 0) - -### 삭제 함수 개선 -- `cleanupOverridesAfterDelete()`: 컴포넌트 삭제 시 모든 오버라이드 정리 -- containers.root.children 정리 -- components 오버라이드 정리 -- 빈 오버라이드 자동 제거 - -### UI 개선 -- 속성 패널에 "표시" 탭 추가 (Eye 아이콘) -- 모드별 체크박스 UI -- 반응형 숨김 (hideBelow) 유지 -- 팔레트에 "줄바꿈" 컴포넌트 추가 - -### 사용 예시 -``` -태블릿 가로: -[A] [B] [C] [D] [E] ← 한 줄 - -모바일 세로: -[A] [B] -─────── ← 줄바꿈 (visibility: mobile만 true) -[C] [D] [E] -``` - -### 수정 파일 -- `pop-layout.ts`: 타입 추가, 삭제 함수 수정 -- `PopFlexRenderer.tsx`: visibility, 병합, pop-break 렌더링 -- `ComponentEditorPanelV4.tsx`: 표시 탭 추가 -- `ComponentPaletteV4.tsx`: 줄바꿈 추가 - ---- - -## [2026-02-04] v4 타입 및 렌더러 - -### Added -- **v4 타입 정의** (간결 버전) - - `PopLayoutDataV4` - 단일 소스 레이아웃 - - `PopContainerV4` - 스택 컨테이너 (direction, wrap, gap, alignItems) - - `PopComponentDefinitionV4` - 크기 제약 기반 (size: fixed/fill/hug) - - `PopSizeConstraintV4` - 크기 규칙 - - `PopResponsiveRuleV4` - 반응형 규칙 (breakpoint별 변경) - - `PopGlobalSettingsV4` - 전역 설정 - - `createEmptyPopLayoutV4()` - 생성 함수 - - `isV4Layout()` - 타입 가드 - - CRUD 함수들 (add, remove, update, find) - -- **PopFlexRenderer.tsx** - v4 Flexbox 렌더러 - - 컨테이너 재귀 렌더링 - - 반응형 규칙 적용 - - 크기 제약 → CSS 변환 - -- **ComponentEditorPanelV4.tsx** - v4 속성 편집 패널 - - 크기 제약 편집 UI - - 컨테이너 설정 UI - -- **PopCanvasV4.tsx** - v4 전용 캔버스 - - 뷰포트 프리셋 - - 너비 슬라이더 - - 줌/패닝 - ---- - -## [2026-02-04] (earlier) - -### Added -- 저장/조회 시스템 구축 - - rangraph: AI 장기 기억 (시맨틱 검색, 요약) - - popdocs: 상세 기록 (파일 기반, 히스토리) - - 이중 저장 체계로 검색 + 기록 분리 - -### Changed -- popdocs 문서 구조 정리 - - README.md: 저장/조회 규칙 추가 - - 기존 문서 archive/로 이동 -- 문서 관리 전략 확정 - - 저장 시: 파일 형식 자동 파악 → 형식 맞춰 추가 → rangraph 요약 - - 조회 시: rangraph 시맨틱 검색 - -### Removed -- .cursorrules 변경 계획 철회 (Git 커밋 영향) - ---- - -## [2026-02-03] - -### Added -- v4 제약조건 기반 레이아웃 계획 - - 단일 소스 + 자동 적응 - - 3가지 규칙 (크기, 배치, 반응형) - - ADR: `decisions/001-v4-constraint-based.md` - ---- - -## [2026-02-02] - -### Fixed -- 캔버스 rowSpan 문제 - - 원인: gridTemplateRows 고정 px - - 해결: `1fr` 사용 - ---- - -## [2026-02-01] - -### Fixed -- 4모드 자동 전환 문제 - - 해결: useResponsiveMode 훅 추가 - ---- - -## [2026-01-31] - -### Added -- v3 섹션 제거, 순수 그리드 구조 -- 4개 모드 독립 그리드 - ---- - -## [2026-01-30] - -### Added -- POP 디자이너 기본 구조 -- PopDesigner, PopCanvas 컴포넌트 - ---- - -## [2026-01-29] - -### Added -- screen_layouts_pop 테이블 -- POP 레이아웃 API (CRUD) - ---- - -*최신이 위, 시간순 역순* diff --git a/popdocs/FILES.md b/popdocs/FILES.md deleted file mode 100644 index 2504a542..00000000 --- a/popdocs/FILES.md +++ /dev/null @@ -1,646 +0,0 @@ -# POP 파일 상세 목록 - -**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)** - -이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다. - ---- - -## 목차 - -1. [App Router 파일](#1-app-router-파일) -2. [Designer 파일](#2-designer-파일) -3. [Panels 파일](#3-panels-파일) -4. [Renderers 파일](#4-renderers-파일) -5. [Types 파일](#5-types-파일) -6. [Utils 파일](#6-utils-파일) -7. [Management 파일](#7-management-파일) -8. [Dashboard 파일](#8-dashboard-파일) -9. [Library 파일](#9-library-파일) -10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일) - ---- - -## 1. App Router 파일 - -### `frontend/app/(pop)/layout.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 전용 레이아웃 래퍼 | -| 라우트 그룹 | `(pop)` - URL에 포함되지 않음 | -| 특징 | 데스크톱과 분리된 터치 최적화 환경 | - ---- - -### `frontend/app/(pop)/pop/page.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 메인 대시보드 | -| 경로 | `/pop` | -| 사용 컴포넌트 | `PopDashboard` | - ---- - -### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 개별 POP 화면 뷰어 (v5 전용) | -| 경로 | `/pop/screens/:screenId` | -| 버전 | v5 그리드 시스템 전용 | - -**핵심 코드 구조**: - -```typescript -// v5 레이아웃 상태 -const [layout, setLayout] = useState(createEmptyPopLayoutV5()); - -// 레이아웃 로드 -useEffect(() => { - const popLayout = await screenApi.getLayoutPop(screenId); - - if (isV5Layout(popLayout)) { - setLayout(popLayout); - } else { - // 레거시 레이아웃은 빈 v5로 처리 - setLayout(createEmptyPopLayoutV5()); - } -}, [screenId]); - -// v5 그리드 렌더링 -{hasComponents ? ( - -) : ( - // 빈 화면 -)} -``` - -**제공 기능**: -- 반응형 모드 감지 (useResponsiveModeWithOverride) -- 프리뷰 모드 (`?preview=true`) -- 디바이스/방향 수동 전환 (프리뷰 모드) -- 4개 그리드 모드 지원 - ---- - -### `frontend/app/(pop)/pop/work/page.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 작업 화면 (샘플) | -| 경로 | `/pop/work` | - ---- - -## 2. Designer 파일 - -### `frontend/components/pop/designer/PopDesigner.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 화면 디자이너 메인 (v5 전용) | -| 의존성 | react-dnd, ResizablePanelGroup | - -**핵심 Props**: - -```typescript -interface PopDesignerProps { - selectedScreen: ScreenDefinition; - onBackToList: () => void; - onScreenUpdate?: (updatedScreen: Partial) => void; -} -``` - -**상태 관리**: - -```typescript -// v5 레이아웃 -const [layout, setLayout] = useState(createEmptyPopLayoutV5()); - -// 선택 상태 -const [selectedComponentId, setSelectedComponentId] = useState(null); - -// 그리드 모드 (4개) -const [currentMode, setCurrentMode] = useState("tablet_landscape"); - -// UI 상태 -const [isLoading, setIsLoading] = useState(true); -const [isSaving, setIsSaving] = useState(false); -const [hasChanges, setHasChanges] = useState(false); -``` - -**주요 핸들러**: - -| 핸들러 | 역할 | -|--------|------| -| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) | -| `handleUpdateComponent` | 컴포넌트 속성 수정 | -| `handleDeleteComponent` | 컴포넌트 삭제 | -| `handleSave` | v5 레이아웃 저장 | - ---- - -### `frontend/components/pop/designer/PopCanvas.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v5 CSS Grid 기반 캔버스 + 행/열 라벨 | -| 렌더링 | CSS Grid (4/6/8/12칸) | -| 모드 | 4개 (태블릿/모바일 x 가로/세로) | -| 라벨 | 열 라벨 (1~12), 행 라벨 (1~20) | -| 토글 | 그리드 ON/OFF 버튼 | - -**핵심 Props**: - -```typescript -interface PopCanvasProps { - layout: PopLayoutDataV5; - selectedComponentId: string | null; - currentMode: GridMode; - onModeChange: (mode: GridMode) => void; - onSelectComponent: (id: string | null) => void; - onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; - onUpdateComponent: (componentId: string, updates: Partial) => void; - onDeleteComponent: (componentId: string) => void; -} -``` - -**뷰포트 프리셋** (v5.2 - height 제거됨, 세로 자동 확장): - -```typescript -const VIEWPORT_PRESETS = [ - { id: "mobile_portrait", label: "모바일 세로", width: 375, columns: 4 }, - { id: "mobile_landscape", label: "모바일 가로", width: 600, columns: 6 }, - { id: "tablet_portrait", label: "태블릿 세로", width: 834, columns: 8 }, - { id: "tablet_landscape", label: "태블릿 가로", width: 1024, columns: 12 }, -]; - -// 세로 자동 확장 -const MIN_CANVAS_HEIGHT = 600; -const CANVAS_EXTRA_ROWS = 3; -const dynamicCanvasHeight = useMemo(() => { ... }, []); -``` - -**제공 기능**: -- 4개 모드 프리셋 전환 -- 줌 컨트롤 (30% ~ 150%) -- 패닝 (Space + 드래그) -- 컴포넌트 드래그 앤 드롭 -- 그리드 좌표 계산 - ---- - -### `frontend/components/pop/designer/index.ts` - -```typescript -export { default as PopDesigner } from "./PopDesigner"; -export { default as PopCanvas } from "./PopCanvas"; -export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel"; -export { default as PopRenderer } from "./renderers/PopRenderer"; -export * from "./types"; -export * from "./utils/gridUtils"; -``` - ---- - -## 3. Panels 파일 - -### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v5 컴포넌트 편집 패널 | -| 위치 | 오른쪽 사이드바 | - -**핵심 Props**: - -```typescript -interface ComponentEditorPanelProps { - component: PopComponentDefinitionV5 | null; - currentMode: GridMode; - onUpdateComponent?: (updates: Partial) => void; - className?: string; -} -``` - -**3개 탭**: - -| 탭 | 아이콘 | 내용 | -|----|--------|------| -| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) | -| `settings` | Settings | 라벨, 타입별 설정 | -| `data` | Database | 데이터 바인딩 (Phase 4) | - ---- - -### `frontend/components/pop/designer/panels/index.ts` - -```typescript -export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel"; -``` - ---- - -## 4. Renderers 파일 - -### `frontend/components/pop/designer/renderers/PopRenderer.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | v5 레이아웃 CSS Grid 렌더러 + 격자 셀 | -| 입력 | PopLayoutDataV5, viewportWidth, currentMode, showGridGuide | -| 격자 | 동적 행 수 (컴포넌트 배치에 따라 자동 계산, CSS Grid 좌표계) | - -**핵심 Props**: - -```typescript -interface PopRendererProps { - layout: PopLayoutDataV5; - viewportWidth: number; - currentMode?: GridMode; - isDesignMode?: boolean; - selectedComponentId?: string | null; - showGridGuide?: boolean; // 격자 표시 여부 - onComponentClick?: (componentId: string) => void; - onBackgroundClick?: () => void; - className?: string; -} -``` - -**격자 셀 렌더링**: - -```typescript -// 동적 행 수 계산 (컴포넌트 배치 기반) -const gridCells = useMemo(() => { - const maxRowEnd = Object.values(components).reduce((max, comp) => { - const pos = getEffectivePosition(comp); - return Math.max(max, pos.row + pos.rowSpan); - }, 1); - const rowCount = Math.max(10, maxRowEnd + 5); - - const cells = []; - for (let row = 1; row <= rowCount; row++) { - for (let col = 1; col <= breakpoint.columns; col++) { - cells.push({ id: `cell-${col}-${row}`, col, row }); - } - } - return cells; -}, [components, overrides, mode, breakpoint.columns]); - -// 컴포넌트와 동일한 CSS Grid 좌표계로 렌더링 -{showGridGuide && gridCells.map(cell => ( -
-))} -``` - -**CSS Grid 스타일 생성**: - -```typescript -const gridStyle: React.CSSProperties = { - display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, - gridAutoRows: `${breakpoint.rowHeight}px`, - gap: `${breakpoint.gap}px`, - padding: `${breakpoint.padding}px`, -}; -``` - -**컴포넌트 위치 변환**: - -```typescript -const convertPosition = (position: PopGridPosition): React.CSSProperties => ({ - gridColumn: `${position.col} / span ${position.colSpan}`, - gridRow: `${position.row} / span ${position.rowSpan}`, -}); -``` - ---- - -### `frontend/components/pop/designer/renderers/index.ts` - -```typescript -export { default as PopRenderer, default } from "./PopRenderer"; -``` - ---- - -## 5. Types 파일 - -### `frontend/components/pop/designer/types/pop-layout.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 레이아웃 v5 타입 시스템 | -| 버전 | v5 전용 (레거시 제거됨) | - -**핵심 타입**: - -```typescript -// 그리드 모드 -type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; - -// 그리드 브레이크포인트 -interface GridBreakpoint { - label: string; - columns: number; - minWidth: number; - maxWidth: number; - rowHeight: number; - gap: number; - padding: number; -} - -// v5 레이아웃 -interface PopLayoutDataV5 { - version: "pop-5.0"; - gridConfig: PopGridConfig; - components: Record; - dataFlow: PopDataFlow; - settings: PopGlobalSettingsV5; - metadata?: PopLayoutMetadata; - overrides?: Record; -} - -// 그리드 위치 -interface PopGridPosition { - col: number; // 시작 열 (1부터) - row: number; // 시작 행 (1부터) - colSpan: number; // 열 크기 - rowSpan: number; // 행 크기 -} - -// v5 컴포넌트 정의 -interface PopComponentDefinitionV5 { - id: string; - type: PopComponentType; - label?: string; - position: PopGridPosition; - visibility?: { modes: GridMode[]; defaultVisible: boolean }; - dataBinding?: PopDataBinding; - style?: PopStylePreset; - config?: PopComponentConfig; -} -``` - -**주요 함수**: - -| 함수 | 역할 | -|------|------| -| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 | -| `addComponentToV5Layout()` | v5에 컴포넌트 추가 | -| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 | -| `isV5Layout()` | v5 타입 가드 | -| `detectGridMode()` | 뷰포트 너비로 모드 감지 | - ---- - -### `frontend/components/pop/designer/types/index.ts` - -```typescript -export * from "./pop-layout"; -``` - ---- - -## 5.5. Constants 파일 (신규) - -### `frontend/components/pop/designer/constants/dnd.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | DnD(Drag and Drop) 관련 상수 | -| 생성일 | 2026-02-05 | - -**핵심 상수**: - -```typescript -export const DND_ITEM_TYPES = { - /** 팔레트에서 새 컴포넌트 드래그 */ - COMPONENT: "POP_COMPONENT", - /** 캔버스 내 기존 컴포넌트 이동 */ - MOVE_COMPONENT: "POP_MOVE_COMPONENT", -} as const; -``` - -**사용처**: -- `PopCanvas.tsx` - useDrop accept 타입 -- `PopRenderer.tsx` - useDrag type -- `ComponentPalette.tsx` - useDrag type - ---- - -### `frontend/components/pop/designer/constants/index.ts` - -```typescript -export * from "./dnd"; -``` - ---- - -## 6. Utils 파일 - -### `frontend/components/pop/designer/utils/gridUtils.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | 그리드 위치 계산 유틸리티 | -| 용도 | 좌표 변환, 겹침 감지, 자동 배치 | - -**주요 함수**: - -| 함수 | 역할 | -|------|------| -| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 | -| `isOverlapping()` | 두 위치 겹침 여부 확인 | -| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) | -| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 | -| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 | -| `isValidPosition()` | 위치 유효성 검사 | -| `clampPosition()` | 위치 범위 조정 | -| `findNextEmptyPosition()` | 다음 빈 위치 찾기 | -| `autoLayoutComponents()` | 자동 배치 | - ---- - -## 7. Management 파일 - -### `frontend/components/pop/management/PopCategoryTree.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 화면 카테고리 트리 | -| 기능 | 그룹 추가/수정/삭제, 화면 목록 | - ---- - -### `frontend/components/pop/management/PopScreenSettingModal.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 화면 설정 모달 | -| 기능 | 화면명, 설명, 그룹 설정 | - ---- - -### `frontend/components/pop/management/PopScreenPreview.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 화면 미리보기 | -| 기능 | 썸네일, 기본 정보 표시 | - ---- - -### `frontend/components/pop/management/PopScreenFlowView.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | 화면 간 플로우 시각화 | -| 기능 | 화면 연결 관계 표시 | - ---- - -### `frontend/components/pop/management/index.ts` - -```typescript -export { PopCategoryTree } from "./PopCategoryTree"; -export { PopScreenSettingModal } from "./PopScreenSettingModal"; -export { PopScreenPreview } from "./PopScreenPreview"; -export { PopScreenFlowView } from "./PopScreenFlowView"; -``` - ---- - -## 8. Dashboard 파일 - -### `frontend/components/pop/dashboard/PopDashboard.tsx` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 대시보드 메인 | -| 구성 | 헤더, KPI, 메뉴그리드, 공지, 푸터 | - ---- - -### 기타 Dashboard 컴포넌트 - -| 파일 | 역할 | -|------|------| -| `DashboardHeader.tsx` | 상단 헤더 | -| `DashboardFooter.tsx` | 하단 푸터 | -| `MenuGrid.tsx` | 메뉴 그리드 | -| `KpiBar.tsx` | KPI 요약 바 | -| `NoticeBanner.tsx` | 공지 배너 | -| `NoticeList.tsx` | 공지 목록 | -| `ActivityList.tsx` | 최근 활동 목록 | - ---- - -## 9. Library 파일 - -### `frontend/lib/api/popScreenGroup.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 화면 그룹 API 클라이언트 | - -**API 함수**: - -```typescript -async function getPopScreenGroups(searchTerm?: string): Promise -async function createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...> -async function updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...> -async function deletePopScreenGroup(id: number): Promise<...> -async function ensurePopRootGroup(): Promise<...> -``` - ---- - -### `frontend/lib/registry/PopComponentRegistry.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 컴포넌트 중앙 레지스트리 | - ---- - -### `frontend/lib/schemas/popComponentConfig.ts` - -| 항목 | 내용 | -|------|------| -| 역할 | POP 컴포넌트 설정 스키마 | -| 검증 | Zod 기반 | - ---- - -## 10. 루트 컴포넌트 파일 - -### `frontend/components/pop/index.ts` - -```typescript -export * from "./designer"; -export * from "./management"; -export * from "./dashboard"; -// 개별 컴포넌트 export -``` - ---- - -### 기타 루트 레벨 컴포넌트 - -| 파일 | 역할 | -|------|------| -| `PopApp.tsx` | POP 앱 셸 | -| `PopHeader.tsx` | 공통 헤더 | -| `PopBottomNav.tsx` | 하단 네비게이션 | -| `PopStatusTabs.tsx` | 상태 탭 | -| `PopWorkCard.tsx` | 작업 카드 | -| `PopProductionPanel.tsx` | 생산 패널 | -| `PopSettingsModal.tsx` | 설정 모달 | -| `PopAcceptModal.tsx` | 수락 모달 | -| `PopProcessModal.tsx` | 프로세스 모달 | -| `PopEquipmentModal.tsx` | 설비 모달 | - ---- - -## 파일 수 통계 - -| 폴더 | 파일 수 | 설명 | -|------|---------|------| -| `app/(pop)` | 4 | App Router 페이지 | -| `components/pop/designer` | 11 | 디자이너 모듈 (v5) - constants 포함 | -| `components/pop/management` | 5 | 관리 모듈 | -| `components/pop/dashboard` | 12 | 대시보드 모듈 | -| `components/pop` (루트) | 15 | 루트 컴포넌트 | -| `lib` | 3 | 라이브러리 | -| **총계** | **50** | | - ---- - -## 삭제된 파일 (v5 통합으로 제거) - -| 파일 | 이유 | -|------|------| -| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 | -| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 | -| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 | -| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 | -| `ComponentEditorPanelV4.tsx` | v4 편집 패널 | -| `PopPanel.tsx` | 레거시 팔레트 패널 | -| `test-v4/page.tsx` | v4 테스트 페이지 | -| `GridGuide.tsx` | SVG 기반 격자 가이드 (좌표 불일치로 삭제, CSS Grid 통합) | - ---- - -*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)* diff --git a/popdocs/INDEX.md b/popdocs/INDEX.md deleted file mode 100644 index 275a5281..00000000 --- a/popdocs/INDEX.md +++ /dev/null @@ -1,120 +0,0 @@ -# 기능별 색인 - -> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘" -> **검색 팁**: Ctrl+F로 기능명, 키워드 검색 - ---- - -## 렌더링 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 | -| 격자 셀 렌더링 | PopRenderer.tsx | `gridCells` (useMemo) | 12x20 = 240개 DOM 셀 | -| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 | -| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 | -| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS | - -## 그리드 가이드 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 (동적 행 수) | -| 열 라벨 | PopCanvas.tsx | `gridLabels.columns` | 1~12 표시 | -| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 동적 계산 (dynamicCanvasHeight 기반) | -| 토글 | PopCanvas.tsx | `showGridGuide` 상태 | 격자 ON/OFF | - -## 세로 자동 확장 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 동적 높이 | PopCanvas.tsx | `dynamicCanvasHeight` | 컴포넌트 배치 기반 자동 계산 | -| 최소 높이 | PopCanvas.tsx | `MIN_CANVAS_HEIGHT` | 600px 보장 | -| 여유 행 | PopCanvas.tsx | `CANVAS_EXTRA_ROWS` | 항상 3행 추가 | -| 격자 행 수 | PopRenderer.tsx | `gridCells` | maxRowEnd + 5 동적 계산 | - -## 드래그 앤 드롭 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 | -| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 | -| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 | -| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 | - -## 편집 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 | -| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 | -| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 | -| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 | - -## 레이아웃 관리 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 | -| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 | -| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 | - -## 상태 관리 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 레이아웃 상태 | PopDesigner.tsx | `useState` | 메인 레이아웃 | -| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo | -| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 | -| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 | - -## 저장/로드 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 | -| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 | -| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 | - -## 뷰포트/줌 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 (width만, height 제거) | -| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% | -| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 | -| 모드 감지 | pop-layout.ts | `detectGridMode()` | 너비 기반 모드 판별 | - -## 브레이크포인트 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 그리드 설정 | pop-layout.ts | `GRID_BREAKPOINTS` | 모드별 칸 수, gap, padding | -| 모드 감지 | pop-layout.ts | `detectGridMode()` | viewportWidth → GridMode | -| 훅 연동 | useDeviceOrientation.ts | `BREAKPOINTS.TABLET_MIN` | 768px (태블릿 경계) | - -## 자동 줄바꿈/검토 - -| 기능 | 파일 | 함수/컴포넌트 | 설명 | -|------|------|--------------|------| -| 자동 배치 | gridUtils.ts | `convertAndResolvePositions()` | col > maxCol → 맨 아래 배치 | -| 검토 필요 판별 | gridUtils.ts | `needsReview()` | 오버라이드 없으면 true | -| 검토 패널 | PopCanvas.tsx | `ReviewPanel` | 검토 필요 컴포넌트 목록 | - ---- - -## 파일별 주요 기능 - -| 파일 | 핵심 기능 | -|------|----------| -| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 | -| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 행/열 라벨, 격자 토글 | -| PopRenderer.tsx | CSS Grid 렌더링, 격자 셀, 위치 변환, 컴포넌트 표시 | -| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) | -| ComponentPalette.tsx | 컴포넌트 팔레트 (드래그 가능한 컴포넌트 목록) | -| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 | -| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 | - ---- - -*새 기능 추가 시 해당 카테고리 테이블에 행 추가* diff --git a/popdocs/PLAN.md b/popdocs/PLAN.md deleted file mode 100644 index f1c9e17c..00000000 --- a/popdocs/PLAN.md +++ /dev/null @@ -1,150 +0,0 @@ -# POP 개발 계획 - ---- - -## 현재 상태 (2026-02-06) - -**v5.2 그리드 시스템 완성 (브레이크포인트 재설계 + 세로 자동 확장)** - ---- - -## 작업 순서 - -``` -[Phase 1~3] [Phase 5] [Phase 4] -v4 Flexbox → v5 CSS Grid → 실제 컴포넌트 구현 - 완료 완료 (v5.2) 다음 -``` - ---- - -## 완료된 Phase - -### Phase 1~3: v4 Flexbox 시스템 (완료, 레거시 삭제됨) - -v4 Flexbox 기반 시스템은 v5 CSS Grid로 완전히 대체되었습니다. -v4 관련 파일은 모두 삭제되었습니다. - -- [x] v4 기본 구조, 렌더러, 디자이너 통합 -- [x] Undo/Redo, 드래그 리사이즈, Flexbox 가로 배치 -- [x] 비율 스케일링 시스템 -- [x] 오버라이드 기능 (모드별 배치 고정) -- [x] 컴포넌트 표시/숨김, 줄바꿈 - -### Phase 5: v5 CSS Grid 시스템 (완료) - -#### Phase 5.1: 타입 정의 (완료) -- [x] `PopLayoutDataV5` 인터페이스 -- [x] `PopGridConfig`, `PopGridPosition` 타입 -- [x] `GridMode`, `GRID_BREAKPOINTS` 상수 -- [x] `createEmptyPopLayoutV5()`, `isV5Layout()`, `detectGridMode()` - -#### Phase 5.2: 그리드 렌더러 (완료) -- [x] `PopRenderer.tsx` - CSS Grid 기반 렌더링 -- [x] 격자 셀 렌더링 (CSS Grid 동일 좌표계) -- [x] 위치 변환 (12칸 -> 4/6/8칸) - -#### Phase 5.3: 디자이너 UI (완료) -- [x] `PopCanvas.tsx` - 그리드 캔버스 + 행/열 라벨 -- [x] 드래그 스냅 (칸에 맞춤) -- [x] `ComponentEditorPanel.tsx` - 위치 편집 - -#### Phase 5.4: 반응형 자동화 (완료) -- [x] 자동 변환 알고리즘 (12칸 -> 4칸) -- [x] 겹침 감지 및 재배치 -- [x] 모드별 오버라이드 저장 - -#### v5.1 추가 기능 (완료) -- [x] 자동 줄바꿈 (col > maxCol -> 맨 아래 배치) -- [x] "검토 필요" 알림 시스템 -- [x] Gap 프리셋 (좁게/보통/넓게) -- [x] 숨김 기능 (모드별) - -#### v5.2 브레이크포인트 재설계 + 세로 자동 확장 (완료) -- [x] 기기 기반 브레이크포인트 (479/767/1023px) -- [x] 세로 자동 확장 (dynamicCanvasHeight) -- [x] 뷰어 반응형 일관성 (detectGridMode 사용) -- [x] VIEWPORT_PRESETS에서 height 제거 - ---- - -## 다음 작업 - -### Phase 4: 실제 컴포넌트 구현 - -현재 모든 컴포넌트는 `pop-sample` (샘플 박스)로 렌더링됩니다. -실제 컴포넌트를 구현하여 데이터 바인딩까지 연결해야 합니다. - -**컴포넌트 구현 목록**: - -- [ ] pop-field: 입력/표시 필드 -- [ ] pop-button: 액션 버튼 -- [ ] pop-list: 데이터 리스트 (카드 템플릿) -- [ ] pop-indicator: KPI/상태 표시 -- [ ] pop-scanner: 바코드/QR 스캔 -- [ ] pop-numpad: 숫자 입력 패드 - -**참고 문서**: [components-spec.md](./components-spec.md) - -### 후속 작업 - -- [ ] 워크플로우 연동 (버튼 액션, 화면 전환) -- [ ] 데이터 바인딩 연결 -- [ ] 실기기 테스트 (아이폰 SE, iPad Mini 등) - ---- - -## 현재 구현 계획 - -> **용도**: 이 섹션은 "지금 바로 실행할 구체적 계획"입니다. -> 새 세션에서 이 섹션만 읽으면 코딩을 시작할 수 있어야 합니다. -> 완료되면 다음 기능의 계획으로 **교체**합니다. - -### 대상: (계획 수립 전) - -현재 구현 계획이 없습니다. 계획 수립 세션에서 다음 형식으로 작성해주세요: - -``` -### 대상: [기능명] - -#### 구현 순서 (의존성 기반) -1. [ ] 파일명 - 변경 내용 요약 -2. [ ] 파일명 - 변경 내용 요약 - -#### 파일별 변경 사항 -| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 | -|---|------------|------|----------|---------| - -#### 함정 경고 -- (빠뜨리면 에러나는 것들) - -#### 참조 -- 관련 문서/파일 경로 -``` - ---- - -## 브레이크포인트 (v5.2 현재) - -| 모드 | 화면 너비 | 칸 수 | 대상 기기 | -|------|----------|-------|----------| -| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S | -| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 | -| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 | -| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 | - ---- - -## 관련 문서 - -| 문서 | 내용 | -|------|------| -| [STATUS.md](./STATUS.md) | 현재 진행 상태 | -| [SPEC.md](./SPEC.md) | 기술 스펙 | -| [ARCHITECTURE.md](./ARCHITECTURE.md) | 코드 구조 | -| [components-spec.md](./components-spec.md) | 컴포넌트 상세 설계 | -| [decisions/005](./decisions/005-breakpoint-redesign.md) | 브레이크포인트 재설계 ADR | - ---- - -*최종 업데이트: 2026-02-06 (v5.2 완료, Phase 4 대기)* diff --git a/popdocs/PROBLEMS.md b/popdocs/PROBLEMS.md deleted file mode 100644 index 1782b57e..00000000 --- a/popdocs/PROBLEMS.md +++ /dev/null @@ -1,254 +0,0 @@ -# 문제-해결 색인 - -> **용도**: "이전에 비슷한 문제 어떻게 해결했어?" -> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등) - ---- - -## 렌더링 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS | -| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 | -| **그리드 가이드 셀 크기 불균일** | gridAutoRows → gridTemplateRows로 행 높이 강제 고정 | 2026-02-06 | gridAutoRows, gridTemplateRows, 셀 크기, CSS Grid | -| **컴포넌트 콘텐츠가 셀 경계 벗어남** | overflow-visible → overflow-hidden 변경 | 2026-02-06 | overflow, 셀 크기, 콘텐츠 | - -## DnD (드래그앤드롭) 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 | -| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd | -| **Expected drag drop context (뷰어)** | isDesignMode=false일 때 DraggableComponent 대신 일반 div 렌더링 | 2026-02-05 | DndProvider, useDrag, 뷰어, context | -| **컴포넌트 중첩(겹침)** | toast import 누락 → `sonner`에서 import | 2026-02-05 | 겹침, overlap, toast | -| **리사이즈 핸들 작동 안됨** | useDrop 2개 중복 → 단일 useDrop으로 통합 | 2026-02-05 | resize, 핸들, useDrop | -| **드래그 좌표 완전 틀림 (Row 92)** | 캔버스 scale 보정 누락 → `(offset - rect.left) / scale` | 2026-02-05 | scale, 좌표, transform | -| **DND 타입 상수 불일치** | 3개 파일에 중복 정의 → `constants/dnd.ts`로 통합 | 2026-02-05 | 상수, DND, 타입 | -| **컴포넌트 이동 안됨** | useDrop accept 타입 불일치 → 공통 상수 사용 | 2026-02-05 | 이동, useDrop, accept | - -## 타입 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props | -| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 | - -## 레이아웃 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| **화면 밖 컴포넌트 정보 손실** | 자동 줄바꿈 로직 추가 (col > maxCol → col=1, row=맨아래+1) | 2026-02-06 | 자동배치, 줄바꿈, 정보손실 | -| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 | -| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 | -| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 | - -## 브레이크포인트/반응형 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| **뷰어 반응형 모드 불일치** | detectGridMode() 사용으로 일관성 확보 | 2026-02-06 | 반응형, 뷰어, 모드 | -| **768~839px 모드 불일치** | TABLET_MIN 768로 변경, 브레이크포인트 재설계 | 2026-02-06 | 브레이크포인트, 768px | -| **useResponsiveMode vs GRID_BREAKPOINTS 불일치** | 뷰어에서 detectGridMode(viewportWidth) 사용 | 2026-02-06 | 훅, 상수, 일관성 | - -## 저장/로드 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 | -| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 | - -## UI/UX 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 | -| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 | - ---- - -## 그리드 가이드 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| SVG 격자와 CSS Grid 좌표 불일치 | GridGuide.tsx 삭제, PopRenderer에서 CSS Grid 셀로 격자 렌더링 | 2026-02-05 | 격자, SVG, CSS Grid, 좌표 | -| 행/열 라벨 위치 오류 | PopCanvas에 absolute positioning 라벨 추가 | 2026-02-05 | 라벨, 행, 열, 정렬 | -| 격자선과 컴포넌트 불일치 | 동일한 CSS Grid 좌표계 사용 | 2026-02-05 | 통합, 정렬, 일체감 | - ---- - -## 해결 완료 (이번 세션) - -| 문제 | 상태 | 해결 방법 | -|------|------|----------| -| PopCanvas 타입 오류 | **해결** | 임시 타입 가드 추가 | -| 팔레트 UI 없음 | **해결** | ComponentPalette.tsx 신규 추가 | -| SVG 격자 좌표 불일치 | **해결** | CSS Grid 기반 통합 | -| 드래그 좌표 완전 틀림 | **해결** | scale 보정 + calcGridPosition 함수 | -| DND 타입 상수 불일치 | **해결** | constants/dnd.ts 통합 | -| 컴포넌트 이동 안됨 | **해결** | useDrop/useDrag 타입 통일 | -| 컴포넌트 중첩(겹침) | **해결** | toast import 추가 → 겹침 감지 로직 정상 작동 | -| 리사이즈 핸들 작동 안됨 | **해결** | useDrop 통합 (2개 → 1개) | -| 숨김 컴포넌트 드래그 안됨 | **해결** | handleMoveComponent에서 숨김 해제 + 위치 저장 단일 상태 업데이트 | -| 그리드 범위 초과 에러 | **해결** | adjustedCol 계산으로 드롭 위치 자동 조정 | -| Expected drag drop context (뷰어) | **해결** | isDesignMode=false일 때 일반 div 렌더링 | -| hiddenComponentIds 중복 정의 | **해결** | 중복 useMemo 제거 (라인 410-412) | -| 뷰어 반응형 모드 불일치 | **해결** | detectGridMode() 사용 | -| 그리드 가이드 셀 크기 불균일 | **해결** | gridTemplateRows로 행 높이 강제 고정 | -| Canvas vs Renderer 행 수 불일치 | **해결** | 숨김 필터 통일, 여유행 +3으로 통일 | -| 디버깅 console.log 잔존 | **해결** | reviewComponents 내 console.log 삭제 | - ---- - -## 드래그 좌표 버그 상세 (2026-02-05) - -### 증상 -- 컴포넌트를 아래로 드래그 → 위로 올라감 -- Row 92 같은 비정상 좌표 -- 드래그 이동/리사이즈 전혀 작동 안됨 - -### 원인 -``` -캔버스: transform: scale(0.8) - -getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px) -getClientOffset() → 뷰포트 기준 실제 마우스 좌표 - -이 둘을 그대로 계산하면 좌표 완전 틀림 -``` - -### 해결 -```typescript -// 스케일 보정된 상대 좌표 계산 -const relX = (offset.x - canvasRect.left) / canvasScale; -const relY = (offset.y - canvasRect.top) / canvasScale; - -// 실제 캔버스 크기로 그리드 계산 -calcGridPosition(relX, relY, customWidth, ...); -``` - -### 교훈 -> CSS `transform: scale()` 적용된 요소에서 좌표 계산 시, -> `getBoundingClientRect()`는 스케일 적용된 값을 반환하지만 -> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요** - ---- - -## Expected drag drop context 에러 상세 (2026-02-05 심야) - -### 증상 -``` -Invariant Violation: Expected drag drop context -at useDrag (...) -at DraggableComponent (...) -``` -뷰어 페이지(`/pop/viewer/[screenId]`)에서 POP 화면 조회 시 에러 발생 - -### 원인 -``` -PopRenderer의 DraggableComponent에서 useDrag 훅을 무조건 호출 -→ 뷰어 페이지에는 DndProvider가 없음 -→ React 훅은 조건부 호출 불가 (Rules of Hooks) -→ DndProvider 없이 useDrag 호출 시 context 에러 -``` - -### 해결 -```typescript -// PopRenderer.tsx - 컴포넌트 렌더링 부분 -if (isDesignMode) { - return ( - // useDrag 사용 - ); -} - -// 뷰어 모드: 드래그 없는 일반 렌더링 -return ( -
- -
-); -``` - -### 교훈 -> React DnD의 `useDrag`/`useDrop` 훅은 반드시 `DndProvider` 내부에서만 호출해야 함. -> 디자인 모드와 뷰어 모드를 분기할 때, 훅이 포함된 컴포넌트 자체를 조건부 렌더링해야 함. -> 훅 내부에서 `canDrag: false`로 설정해도 훅 자체는 호출되므로 context 에러 발생. - -### 관련 파일 -- `gridUtils.ts`: convertAndResolvePositions(), needsReview() -- `PopCanvas.tsx`: ReviewPanel, ReviewItem -- `PopRenderer.tsx`: 자동 배치 위치 렌더링 - ---- - -## 뷰어 반응형 모드 불일치 상세 (2026-02-06) - -### 증상 -``` -- 아이폰 SE, iPad Pro 프리셋은 정상 작동 -- 브라우저 수동 리사이즈 시 6칸 모드(mobile_landscape)가 적용 안 됨 -- 768~839px 구간에서 8칸으로 표시됨 (예상: 6칸) -``` - -### 원인 -``` -useResponsiveMode 훅: -- deviceType: width/height 비율로 "mobile"/"tablet" 판정 -- isLandscape: width > height로 판정 -- BREAKPOINTS.TABLET_MIN = 840 (당시) - -GRID_BREAKPOINTS: -- mobile_landscape: 600~839px (6칸) -- tablet_portrait: 840~1023px (8칸) - -결과: -- 768px 화면 → useResponsiveMode: "tablet" (768 < 840이지만 비율 판정) -- 768px 화면 → GRID_BREAKPOINTS: "mobile_landscape" (6칸) -- → 모드 불일치! -``` - -### 해결 - -**1단계: 브레이크포인트 재설계** -```typescript -// 기존 -mobile_landscape: { minWidth: 600, maxWidth: 839 } -tablet_portrait: { minWidth: 840, maxWidth: 1023 } - -// 변경 후 -mobile_landscape: { minWidth: 480, maxWidth: 767 } -tablet_portrait: { minWidth: 768, maxWidth: 1023 } -``` - -**2단계: 훅 연동** -```typescript -// useDeviceOrientation.ts -BREAKPOINTS.TABLET_MIN: 768 // was 840 -``` - -**3단계: 뷰어 모드 감지 방식 변경** -```typescript -// page.tsx (뷰어) -const currentModeKey = isPreviewMode - ? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택 - : detectGridMode(viewportWidth); // 일반: 너비 기반 (일관성 확보) -``` - -### 교훈 -> 반응형 모드 판정은 **단일 소스(GRID_BREAKPOINTS)**를 기준으로 해야 함. -> 훅과 상수가 각각 다른 기준을 사용하면 구간별 불일치 발생. -> 뷰어에서는 `detectGridMode(viewportWidth)` 직접 사용으로 일관성 확보. - ---- - -## 병합 관련 - -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| **ScreenDesigner.tsx 3건 충돌** (origin/main 병합) | 함수 시그니처: ksh-v2-work 유지(isPop/defaultDevicePreview), 저장 로직: 3단계 분기 유지+console.log 제거, 툴바 props: origin/main 채택 | 2026-02-09 | 병합, merge, ScreenDesigner, 충돌 | -| **usePanelState 중복 선언** (병합 시 발견) | 충돌 1 해결 과정에서 L175의 중복 usePanelState 제거, L215의 완전한 버전만 유지 | 2026-02-09 | usePanelState, 중복, 병합 | -| **툴바 JSX 들여쓰기 불일치** (병합 후 린트) | origin/main 코드가 ksh-v2-work와 2칸 들여쓰기 차이. 기능 영향 없음. 추후 포매팅 정리 권장 | 2026-02-09 | 들여쓰기, 포매팅, 린트, prettier | - ---- - -*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가* diff --git a/popdocs/README.md b/popdocs/README.md deleted file mode 100644 index aa2db7e0..00000000 --- a/popdocs/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# POP 화면 시스템 - -> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요. -> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조 - ---- - -## 현재 상태 - -| 항목 | 값 | -|------|-----| -| 버전 | **v5.2** (브레이크포인트 재설계 + 세로 자동 확장) | -| 상태 | **반응형 시스템 완성** | -| 다음 | Phase 4 (실제 컴포넌트 구현) | - -**마지막 업데이트**: 2026-02-06 - ---- - -## 마지막 대화 요약 - -> **v5.2.1 그리드 셀 크기 강제 고정**: -> - gridAutoRows → gridTemplateRows로 행 높이 강제 고정 -> - "셀의 크기 = 컴포넌트의 크기" 원칙을 코드 수준에서 강제 -> - Canvas/Renderer 간 행 수 계산 기준 통일 (숨김 필터, 여유행 +3) -> -> 다음: Phase 4 (실제 컴포넌트 구현) - ---- - -## 빠른 경로 - -| 알고 싶은 것 | 문서 | -|--------------|------| -| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) | -| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) | -| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) | -| 그리드 가이드 설계 | [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) | -| 브레이크포인트 재설계 | [decisions/005-breakpoint-redesign.md](./decisions/005-breakpoint-redesign.md) | -| 자동 줄바꿈 시스템 | [decisions/006-auto-wrap-review-system.md](./decisions/006-auto-wrap-review-system.md) | -| 개발 계획/로드맵 | [PLAN.md](./PLAN.md) | -| 지금 바로 코딩할 계획 | [PLAN.md "현재 구현 계획"](./PLAN.md#현재-구현-계획) | -| 작업 프롬프트 | [WORKFLOW_PROMPTS.md](./WORKFLOW_PROMPTS.md) | -| 컴포넌트 상세 설계 | [components-spec.md](./components-spec.md) | -| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) | -| 코드 어디 있어? | [FILES.md](./FILES.md) | -| 기능별 색인 | [INDEX.md](./INDEX.md) | -| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) | - ---- - -## 핵심 파일 - -| 파일 | 역할 | 경로 | -|------|------|------| -| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` | -| 캔버스 | 그리드 캔버스 + DnD + 라벨 | `frontend/components/pop/designer/PopCanvas.tsx` | -| 렌더러 | CSS Grid 렌더링 + 격자 셀 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` | -| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` | -| 팔레트 | 컴포넌트 목록 | `frontend/components/pop/designer/panels/ComponentPalette.tsx` | - ---- - -## 문서 구조 - -``` -[Layer 1: 먼저 읽기] -README.md (지금 여기) → STATUS.md - -[Layer 2: 필요시 읽기] -CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN - -[Layer 3: 심화] -decisions/, sessions/, archive/ -``` - -**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로. - ---- - -## POP이란? - -**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템 - -| 용도 | 경로 | -|------|------| -| 뷰어 | `/pop/screens/{screenId}` | -| 관리 | `/admin/screenMng/popScreenMngList` | -| API | `/api/screen-management/layout-pop/:screenId` | - ---- - -## v5 그리드 시스템 (현재) - -| 모드 | 화면 너비 | 칸 수 | 대상 기기 | -|------|----------|-------|----------| -| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S | -| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 | -| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 | -| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 | - -**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan) - -**세로 무한 스크롤**: 캔버스 높이 자동 확장 (컴포넌트 배치에 따라) - -**그리드 가이드**: CSS Grid 기반 격자 셀 + 행/열 라벨 (ON/OFF 토글) - ---- - -*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)* diff --git a/popdocs/SAVE_RULES.md b/popdocs/SAVE_RULES.md deleted file mode 100644 index fc445170..00000000 --- a/popdocs/SAVE_RULES.md +++ /dev/null @@ -1,574 +0,0 @@ -# popdocs 사용 규칙 - -> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다. -> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요. - ---- - -## 요청 유형 인식 - -### 키워드로 요청 유형 판별 - -| 유형 | 키워드 예시 | 행동 | -|------|------------|------| -| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 | -| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 | -| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 | -| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 | -| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 | -| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 | - -### 요청 유형별 행동 - -``` -[저장 요청] -"@popdocs 오늘 작업 저장해줘" -→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화 - -[조회 요청] -"@popdocs 이전에 DnD 문제 어떻게 해결했어?" -→ PROBLEMS.md 검색 → 관련 내용만 반환 - -[분석 요청] -"@popdocs v4랑 v5 뭐가 달라?" -→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성 - -[수정 요청] -"@popdocs STATUS 업데이트해줘" -→ STATUS.md 수정 → README.md 동기화 - -[요약 요청] -"@popdocs 이번 주 작업 요약해줘" -→ sessions/ 해당 기간 검색 → 요약 생성 - -[계획 저장] -"@popdocs 구현 계획 저장해줘" -→ PLAN.md "현재 구현 계획" 섹션 교체 → STATUS.md 동기화 - -[작업 시작] -"@popdocs 오늘 작업 시작하자" -→ README → STATUS → PLAN.md "현재 구현 계획" → 중단점 확인 → 작업 시작 -``` - ---- - -## 컨텍스트 효율화 원칙 - -### Progressive Disclosure (점진적 공개) - -**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로. - -``` -Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄) -Layer 2 (상세) → 필요한 문서만 선택적으로 -Layer 3 (심화) → 코드 파일, archive/ (필요시만) -``` - -### Token as Currency (토큰은 자원) - -| 원칙 | 설명 | -|------|------| -| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 | -| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 | -| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 | -| **테이블 > 산문** | 긴 설명 대신 표로 압축 | -| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 | - -### Context Bloat 방지 - -``` -❌ 잘못된 방법: -"모든 문서를 읽고 파악한 후 작업하겠습니다" -→ 1,300줄 이상 낭비 - -✅ 올바른 방법: -"README → STATUS → 필요한 섹션만" -→ 평균 50~100줄로 작업 가능 -``` - ---- - -## 문서 구조 (3계층) - -``` -popdocs/ -│ -├── [Layer 1: 진입점] ───────────────────────── -│ ├── README.md ← 시작점 (현재 상태 요약) -│ ├── STATUS.md ← 진행 상태, 다음 작업 -│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서) -│ -├── [Layer 2: 상세 문서] ───────────────────────── -│ ├── CHANGELOG.md ← 변경 이력 (날짜별) -│ ├── PROBLEMS.md ← 문제-해결 색인 -│ ├── INDEX.md ← 기능별 색인 -│ ├── ARCHITECTURE.md ← 코드 구조 -│ ├── FILES.md ← 파일 목록 -│ ├── SPEC.md ← 기술 스펙 -│ └── PLAN.md ← 계획 -│ -├── [Layer 3: 심화/기록] ───────────────────────── -│ ├── decisions/ ← ADR (결정 기록) -│ ├── sessions/ ← 날짜별 작업 기록 -│ └── archive/ ← 보관 (레거시) -│ -└── [외부 참조] ───────────────────────── - └── 실제 코드 → frontend/components/pop/designer/ -``` - ---- - -## 조회 규칙 (읽기) - -### 작업 시작 시 - -``` -1. README.md 읽기 (~60줄) - └→ 현재 상태, 다음 작업 확인 - -2. STATUS.md 읽기 (~40줄) - └→ 상세 진행 상황, 중단점 확인 - -3. 필요한 문서만 선택적으로 -``` - -### 요청별 조회 경로 - -| 사용자 요청 | 조회 경로 | -|-------------|----------| -| "지금 뭐 해야 해?" | README → STATUS | -| "구현 계획 보여줘" | PLAN.md "현재 구현 계획" 섹션 | -| "어제 뭐 했어?" | sessions/어제날짜.md | -| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) | -| "이 기능 어디있어?" | INDEX.md 또는 FILES.md | -| "왜 이렇게 결정했어?" | decisions/ | -| "전체 히스토리" | CHANGELOG.md (기간 한정) | -| "코드 구조 알려줘" | ARCHITECTURE.md | -| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG | - -### 효율적 검색 - -``` -# 전체 파일 읽지 말고 키워드 검색 -PROBLEMS.md에서 "DnD" 검색 → 관련 행만 -CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만 -FILES.md에서 "렌더러" 검색 → 관련 파일만 -``` - ---- - -## 저장 규칙 (쓰기) - -### 저장 유형별 위치 - -| 요청 패턴 | 저장 위치 | 형식 | -|----------|----------|------| -| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 | -| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 | -| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 | -| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 | -| "현재 상태 업데이트" | STATUS.md | 상태 수정 | -| "기능 색인 추가해줘" | INDEX.md | 행 추가 | -| "구현 계획 저장해줘" | PLAN.md "현재 구현 계획" | 섹션 교체 | - -### 저장 후 필수 동기화 - -``` -저장 완료 후 항상: -1. STATUS.md 업데이트 (진행 상태, 다음 작업) -2. README.md "마지막 대화 요약" 업데이트 (1-2줄) -``` - ---- - -## 분석/비교 규칙 - -### 비교 요청 시 - -``` -사용자: "@popdocs v4랑 v5 뭐가 달라?" - -AI 행동: -1. decisions/003-v5-grid-system.md 확인 (있으면) -2. 없으면 CHANGELOG에서 관련 날짜 검색 -3. 비교표 형식으로 응답 - -응답 형식: -| 항목 | v4 | v5 | -|------|----|----| -| 배치 | Flexbox | CSS Grid | -| ... | ... | ... | -``` - -### 분석 요청 시 - -``` -사용자: "@popdocs 이번 달 작업 분석해줘" - -AI 행동: -1. sessions/ 폴더에서 해당 기간 파일 목록 -2. 각 파일의 "요약" 섹션만 추출 -3. 종합 분석 제공 -``` - ---- - -## 수정 규칙 - -### 문서 수정 요청 시 - -``` -사용자: "@popdocs STATUS 업데이트해줘" - -AI 행동: -1. STATUS.md 읽기 -2. 변경 내용 적용 -3. README.md 동기화 (마지막 대화 요약) -4. 변경 내용 사용자에게 확인 -``` - -### 여러 문서 수정 시 - -``` -수정 순서: -상세 문서 먼저 → STATUS.md → README.md -(역방향: 진입점이 항상 최신 상태 유지) -``` - ---- - -## 요약/보고서 규칙 - -### 요약 요청 시 - -``` -사용자: "@popdocs 이번 주 요약해줘" - -AI 행동: -1. sessions/ 해당 기간 파일 확인 -2. 각 파일의 "요약" + "완료" 섹션 추출 -3. 압축된 형식으로 응답 - -응답 형식: -## 이번 주 요약 (02-01 ~ 02-05) -| 날짜 | 주요 작업 | -|------|----------| -| 02-05 | v5 통합 완료 | -| 02-04 | ... | -``` - -### 보고서 생성 요청 시 - -``` -사용자: "@popdocs 진행 보고서 만들어줘" - -AI 행동: -1. STATUS.md (현재 상태) -2. CHANGELOG.md (최근 작업) -3. sessions/ (상세 기록) -4. 보고서 형식으로 조합 - -→ 새 파일 생성하지 말고 응답으로 제공 -→ 필요시 사용자가 저장 요청 -``` - ---- - -## 예외 처리 - -### 분류 불가 시 - -``` -사용자: "@popdocs 이거 저장해줘" (유형 불명확) - -AI 행동: -→ 사용자에게 질문: "어떤 유형으로 저장할까요?" - - 오늘 작업 기록 (sessions/) - - 문제-해결 기록 (PROBLEMS.md) - - 결정 사항 (decisions/) - - 변경 이력 (CHANGELOG.md) -``` - -### 복합 요청 시 - -``` -사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘" - -AI 행동 (순서): -1. PROBLEMS.md에 문제-해결 추가 -2. decisions/에 ADR 생성 (필요시) -3. CHANGELOG.md에 섹션 추가 -4. STATUS.md 업데이트 -5. README.md 동기화 -``` - -### 전체 조회 요청 시 - -``` -사용자: "@popdocs 전체 히스토리 보여줘" - -AI 행동: -→ "기간을 지정해주시면 더 정확히 찾아드릴게요." - - 예: "최근 1주일", "2월 작업", "v5 관련" - -→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만 -``` - -### 파일 없음 시 - -``` -사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음) - -AI 행동: -→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?" -``` - -### 키워드 검색 실패 시 - -``` -사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음) - -AI 행동: -→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?" - - CHANGELOG.md - - INDEX.md - - sessions/ -``` - ---- - -## 동기화 규칙 - -### 항상 동기화해야 하는 쌍 - -| 변경 문서 | 동기화 대상 | -|----------|-----------| -| sessions/ 생성 | STATUS.md (최근 세션) | -| PROBLEMS.md 추가 | - | -| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md | -| CHANGELOG.md 추가 | STATUS.md (진행 상태) | -| STATUS.md 수정 | README.md (마지막 요약) | -| PLAN.md 구현 계획 수정 | STATUS.md (다음 작업) | - -### 불일치 발견 시 - -``` -README.md와 STATUS.md 내용이 다르면: -→ STATUS.md를 정본(正本)으로 -→ README.md를 STATUS.md 기준으로 업데이트 -``` - ---- - -## 정리 규칙 - -### 주기적 정리 (수동 요청 시) - -| 대상 | 조건 | 조치 | -|------|------|------| -| sessions/ | 30일 이상 | archive/sessions/로 이동 | -| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 | -| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 | - -### 정리 요청 패턴 - -``` -사용자: "@popdocs 오래된 파일 정리해줘" - -AI 행동: -1. sessions/ 30일 이상 파일 목록 제시 -2. 사용자 확인 후 archive/로 이동 -3. 강제 삭제하지 않음 -``` - ---- - -## 템플릿 - -### 세션 기록 (sessions/YYYY-MM-DD.md) - -```markdown -# YYYY-MM-DD 작업 기록 - -## 요약 -(한 줄 요약 - 50자 이내) - -## 완료 -- [x] 작업1 -- [x] 작업2 - -## 미완료 -- [ ] 작업3 (이유: ...) - -## 중단점 -> (내일 이어서 할 때 바로 시작할 수 있는 정보) - -## 대화 핵심 -- 키워드1: 설명 -- 키워드2: 설명 - -## 관련 링크 -- CHANGELOG: #YYYY-MM-DD -- ADR: decisions/NNN (있으면) -``` - -### 문제-해결 (PROBLEMS.md 행 추가) - -```markdown -| 문제 | 해결 | 날짜 | 키워드 | -|------|------|------|--------| -| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 | -``` - -### ADR (decisions/NNN-제목.md) - -```markdown -# ADR-NNN: 제목 - -**날짜**: YYYY-MM-DD -**상태**: 채택됨 - -## 배경 (왜) -(2-3문장) - -## 결정 (무엇) -(핵심 결정 사항) - -## 대안 -| 옵션 | 장점 | 단점 | 결과 | -|------|------|------|------| - -## 교훈 -- (배운 점) -``` - -### 구현 계획 (PLAN.md "현재 구현 계획" 교체) - -```markdown -### 대상: [기능명] - -#### 구현 순서 (의존성 기반) -1. [ ] 파일명 - 변경 내용 요약 -2. [ ] 파일명 - 변경 내용 요약 - -#### 파일별 변경 사항 -| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 | -|---|------------|------|----------|---------| -| 1 | path/file.tsx (신규) | 생성 | 설명 | 주의 | -| 2 | path/file.tsx (수정) | 수정 | 설명 | 주의 | - -#### 함정 경고 -- (빠뜨리면 에러나는 것들) - -#### 참조 -- 관련 문서/파일 경로 -``` - -**라이프사이클**: -- 계획 수립 시: "현재 구현 계획" 섹션을 새 계획으로 **교체** -- 코딩 중: 완료 항목 `[ ]` → `[x]` -- 기능 완료 시: 다음 기능 계획으로 **교체** (이전 계획은 CHANGELOG에 기록됨) -- 항상 1개만 존재 - -### CHANGELOG 섹션 - -```markdown -## [YYYY-MM-DD] 제목 - -### 배경 -(왜 - 2-3문장) - -### 작업 -- [x] 완료1 -- [ ] 미완료 → STATUS.md로 - -### 다음 -→ STATUS.md 참조 -``` - ---- - -## 토큰 비용 가이드 - -| 문서 | 평균 줄 수 | 언제 읽나 | -|------|-----------|----------| -| README.md | ~60 | 항상 (진입점) | -| STATUS.md | ~40 | 항상 (진입점) | -| SAVE_RULES.md | ~350 | 저장/조회 요청 시 | -| sessions/날짜.md | ~30 | 해당 날짜만 | -| PROBLEMS.md | ~100 | 키워드 검색 | -| INDEX.md | ~80 | 키워드 검색 | -| CHANGELOG.md | ~600 | 날짜/키워드 검색 | -| ARCHITECTURE.md | ~300 | 구조 파악 시 | -| FILES.md | ~200 | 파일 찾을 때 | - -**권장**: README + STATUS = ~100줄로 작업 시작 - ---- - -## 예시 워크플로우 - -### 1. 작업 시작 - -``` -사용자: "@popdocs 오늘 작업 시작하자" - -AI: -1. README.md 읽기 (60줄) -2. STATUS.md 읽기 (40줄) -3. 중단점 확인 -4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?" -``` - -### 2. 자료 검색 - -``` -사용자: "@popdocs DnD 관련 자료 찾아줘" - -AI: -1. PROBLEMS.md에서 "DnD" 검색 -2. INDEX.md에서 "DnD" 검색 -3. 관련 내용 종합해서 응답 -4. 전체 파일 읽지 않음 -``` - -### 3. 비교 요청 - -``` -사용자: "@popdocs v4랑 v5 비교해줘" - -AI: -1. decisions/003-v5-grid-system.md 확인 -2. "대안 검토" 섹션에서 비교표 추출 -3. 필요시 CHANGELOG 보충 -``` - -### 4. 작업 저장 - -``` -사용자: "@popdocs 오늘 작업 저장해줘" - -AI: -1. sessions/2026-02-05.md 생성 -2. STATUS.md 업데이트 -3. README.md "마지막 대화 요약" 업데이트 -4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다." -``` - -### 5. 불명확한 요청 - -``` -사용자: "@popdocs 이거 저장해줘" - -AI: -"어떤 유형으로 저장할까요? -1. 오늘 작업 기록 (sessions/) -2. 문제-해결 (PROBLEMS.md) -3. 결정 사항 (decisions/) -4. 변경 이력 (CHANGELOG.md)" -``` - ---- - -*2025-2026 AI 컨텍스트 엔지니어링 기반* -*Progressive Disclosure, Agentic Context Engineering, Token as Currency* diff --git a/popdocs/SPEC.md b/popdocs/SPEC.md deleted file mode 100644 index dd391729..00000000 --- a/popdocs/SPEC.md +++ /dev/null @@ -1,236 +0,0 @@ -# POP 기술 스펙 - -**버전: v5 (CSS Grid 기반)** - ---- - -## v5 핵심 규칙 - -### 1. 그리드 시스템 - -| 모드 | 화면 너비 | 칸 수 | 대상 기기 | -|------|----------|-------|----------| -| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S (세로) | -| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로, 작은 태블릿 | -| tablet_portrait | 768~1023px | 8칸 | iPad Mini ~ iPad Pro (세로) | -| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 (기본) | - -> **브레이크포인트 기준**: 실제 기기 CSS 뷰포트 너비 기반 (2026-02-06 재설계) - -### 2. 위치 지정 - -```typescript -interface PopGridPosition { - col: number; // 시작 열 (1부터) - row: number; // 시작 행 (1부터) - colSpan: number; // 열 크기 (1~12) - rowSpan: number; // 행 크기 (1~) -} -``` - -### 3. 브레이크포인트 설정 - -```typescript -const GRID_BREAKPOINTS = { - mobile_portrait: { - columns: 4, - rowHeight: 48, - gap: 8, - padding: 12, - maxWidth: 479, // 아이폰 SE (375px) ~ 갤럭시 S (360px) - }, - mobile_landscape: { - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - minWidth: 480, - maxWidth: 767, // 스마트폰 가로 - }, - tablet_portrait: { - columns: 8, - rowHeight: 52, - gap: 12, - padding: 20, - minWidth: 768, // iPad Mini 세로 (768px) - maxWidth: 1023, - }, - tablet_landscape: { - columns: 12, - rowHeight: 56, - gap: 12, - padding: 24, - minWidth: 1024, // iPad Pro 11 가로 (1194px), 12.9 가로 (1366px) - }, -}; -``` - -### 4. 세로 자동 확장 - -```typescript -// 캔버스 높이 동적 계산 -const MIN_CANVAS_HEIGHT = 600; // 최소 높이 (px) -const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수 - -const dynamicCanvasHeight = useMemo(() => { - // 가장 아래 컴포넌트 위치 계산 - const maxRowEnd = visibleComps.reduce((max, comp) => { - const rowEnd = pos.row + pos.rowSpan; - return Math.max(max, rowEnd); - }, 1); - - // 여유 행 추가하여 높이 계산 - const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS; - return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight + padding); -}, [layout.components, ...]); -``` - -**특징**: -- 디자이너: 세로 무한 확장 (컴포넌트 추가에 제한 없음) -- 뷰어: 터치 스크롤로 아래 컴포넌트 접근 가능 - ---- - -## 데이터 구조 - -### v5 레이아웃 - -```typescript -interface PopLayoutDataV5 { - version: "pop-5.0"; - metadata: { - screenId: number; - createdAt: string; - updatedAt: string; - }; - gridConfig: { - defaultMode: GridMode; - maxRows: number; - }; - components: PopComponentDefinitionV5[]; - globalSettings: { - backgroundColor: string; - padding: number; - }; -} -``` - -### v5 컴포넌트 - -```typescript -interface PopComponentDefinitionV5 { - id: string; - type: PopComponentType; // "pop-label" | "pop-button" | ... - label: string; - gridPosition: PopGridPosition; - config: PopComponentConfig; - visibility: Record; // 모드별 표시/숨김 - modeOverrides?: Record; // 모드별 오버라이드 -} -``` - -### 컴포넌트 타입 - -```typescript -type PopComponentType = - | "pop-label" // 텍스트 라벨 - | "pop-button" // 버튼 - | "pop-input" // 입력 필드 - | "pop-select" // 선택 박스 - | "pop-grid" // 데이터 그리드 - | "pop-container"; // 컨테이너 -``` - ---- - -## 크기 프리셋 - -### 터치 요소 - -| 요소 | 일반 | 산업용 | -|------|-----|-------| -| 버튼 높이 | 48px | 60px | -| 입력창 높이 | 48px | 56px | -| 터치 영역 | 48px | 60px | - -### 폰트 (clamp) - -| 용도 | 범위 | CSS | -|------|-----|-----| -| 본문 | 14-18px | `clamp(14px, 1.5vw, 18px)` | -| 제목 | 18-28px | `clamp(18px, 2.5vw, 28px)` | - -### 간격 - -| 이름 | 값 | 용도 | -|------|---|-----| -| sm | 8px | 요소 내부 | -| md | 16px | 컴포넌트 간 | -| lg | 24px | 섹션 간 | - ---- - -## 반응형 원칙 - -``` -누르는 것 → 고정 (48px) - 버튼, 터치 영역 -읽는 것 → 범위 (clamp) - 텍스트 -담는 것 → 칸 (colSpan) - 컨테이너 -``` - ---- - -## 위치 변환 - -12칸 기준으로 설계 → 다른 모드에서 자동 변환 - -```typescript -// 12칸 → 4칸 변환 예시 -const ratio = 4 / 12; // = 0.333 - -original: { col: 1, colSpan: 6 } // 12칸에서 절반 -converted: { col: 1, colSpan: 2 } // 4칸에서 절반 -``` - ---- - -## Troubleshooting - -### 컴포넌트가 얇게 보임 - -- **증상**: rowSpan이 적용 안됨 -- **원인**: gridTemplateRows 고정 px -- **해결**: `1fr` 사용 - -### 모드 전환 안 됨 - -- **증상**: 화면 크기 변경해도 레이아웃 유지 -- **해결**: `detectGridMode()` 사용 - -### 겹침 발생 - -- **증상**: 컴포넌트끼리 겹침 -- **해결**: `resolveOverlaps()` 호출 - ---- - -## 타입 가드 - -```typescript -// v5 레이아웃 판별 -function isV5Layout(data: any): data is PopLayoutDataV5 { - return data?.version === "pop-5.0"; -} - -// 사용 예시 -if (isV5Layout(savedData)) { - setLayout(savedData); -} else { - setLayout(createEmptyPopLayoutV5()); -} -``` - ---- - -*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)* -*파일 목록: [FILES.md](./FILES.md)* diff --git a/popdocs/STATUS.md b/popdocs/STATUS.md deleted file mode 100644 index b21e4547..00000000 --- a/popdocs/STATUS.md +++ /dev/null @@ -1,126 +0,0 @@ -# 현재 상태 - -> **마지막 업데이트**: 2026-02-06 -> **담당**: POP 화면 디자이너 - ---- - -## 진행 상태 - -| 단계 | 상태 | 설명 | -|------|------|------| -| v5 타입 정의 | 완료 | `pop-layout.ts` | -| v5 렌더러 | 완료 | `PopRenderer.tsx` | -| v5 캔버스 | 완료 | `PopCanvas.tsx` | -| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` | -| v5 유틸리티 | 완료 | `gridUtils.ts` | -| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 | -| 문서 정리 | 완료 | popdocs v5 기준 재정비 | -| 컴포넌트 팔레트 | 완료 | `ComponentPalette.tsx` | -| 드래그앤드롭 | 완료 | 스케일 보정, DND 상수 통합 | -| 그리드 가이드 재설계 | 완료 | CSS Grid 기반 통합 | -| 모드별 오버라이드 | 완료 | 위치/크기 모드별 저장 | -| 화면 밖 컴포넌트 | 완료 | 오른쪽 패널 배치, 드래그로 복원 | -| 숨김 기능 | 완료 | 모드별 숨김/숨김해제 | -| 리사이즈 겹침 검사 | 완료 | 실시간 겹침 방지 | -| Gap 프리셋 | 완료 | 좁게/보통/넓게 간격 조정 | -| **자동 줄바꿈** | **완료** | col > maxCol → 맨 아래 배치 | -| **검토 필요 시스템** | **완료** | 오버라이드 없으면 검토 알림 | -| **브레이크포인트 재설계** | **완료** | 기기 기반 (479/767/1023px) | -| **세로 자동 확장** | **완료** | 캔버스 높이 동적 계산 | -| **그리드 셀 크기 강제 고정** | **완료** | gridTemplateRows로 행 높이 고정, overflow-hidden | - ---- - -## 다음 작업 (우선순위) - -1. **실제 컴포넌트 구현** (Phase 4) - - pop-label, pop-button 등 실제 렌더링 - - 데이터 바인딩 연결 - -2. **워크플로우 연동** - - 버튼 액션 연결 - - 화면 전환 로직 - ---- - -## 최근 주요 변경 (2026-02-06) - -### 브레이크포인트 재설계 -| 모드 | 변경 전 | 변경 후 | 근거 | -|------|--------|--------|------| -| mobile_portrait | ~599px | ~479px | 스마트폰 세로 | -| mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 | -| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 포함 | -| tablet_landscape | 1024px+ | 동일 | - | - -### 세로 자동 확장 -| 기능 | 설명 | -|------|------| -| 동적 캔버스 높이 | 컴포넌트 배치에 따라 자동 계산 | -| 최소 높이 | 600px 보장 | -| 여유 행 | 항상 3행 추가 | -| 뷰어 스크롤 | 터치 스크롤로 아래 컴포넌트 접근 | - -### v5.1 자동 줄바꿈 시스템 -| 기능 | 설명 | -|------|------| -| 자동 줄바꿈 | col > maxCol인 컴포넌트를 맨 아래에 자동 배치 | -| 정보 손실 방지 | 모든 컴포넌트가 항상 그리드 안에 표시됨 | -| 검토 필요 알림 | 오버라이드 없으면 "검토 필요" 패널 표시 | -| 검토 완료 | 편집하면 오버라이드 저장, 검토 필요에서 제거 | - -### 기존 기능 유지 (2026-02-05 심야) -| 기능 | 설명 | -|------|------| -| 모드별 재배치 | 4/6/8/12칸 모드별로 컴포넌트 위치/크기 개별 저장 | -| 자동 레이아웃 고정 | 드래그/리사이즈 시 자동으로 오버라이드 저장 | -| 원본으로 되돌리기 | 오버라이드 삭제하여 자동 재배치로 복원 | -| 숨김 기능 | 특정 모드에서 컴포넌트 의도적 숨김 (검토와 별개) | - ---- - -## 알려진 문제 - -| 문제 | 상태 | 비고 | -|------|------|------| -| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 | -| SVG 격자 좌표 불일치 | 해결됨 | GridGuide 삭제, CSS Grid 통합 | -| 드래그 좌표 계산 오류 | 해결됨 | 스케일 보정 적용 | -| DND 타입 상수 불일치 | 해결됨 | constants/dnd.ts로 통합 | -| 숨김 컴포넌트 드래그 안됨 | 해결됨 | 상태 업데이트 순서 수정 | -| 그리드 범위 초과 에러 | 해결됨 | 드롭 위치 자동 조정 | -| Expected drag drop context | 해결됨 | 뷰어 모드에서 일반 div 렌더링 | -| Gap 프리셋 UI 안 보임 | 해결됨 | 그리드 라벨에 adjustedGap 적용 | -| 화면 밖 컴포넌트 정보 손실 | 해결됨 | 자동 줄바꿈으로 항상 그리드 안에 배치 | -| 뷰어 반응형 모드 불일치 | 해결됨 | detectGridMode() 사용으로 일관성 확보 | -| hiddenComponentIds 중복 정의 | 해결됨 | 중복 useMemo 제거 | -| 그리드 가이드 셀 크기 불균일 | 해결됨 | gridTemplateRows로 행 높이 강제 고정 | -| Canvas/Renderer 행 수 불일치 | 해결됨 | 숨김 필터 통일, 여유행 +3 | -| 디버깅 console.log 잔존 | 해결됨 | reviewComponents 내 삭제 | - ---- - -## 최근 세션 - -| 날짜 | 요약 | 상세 | -|------|------|------| -| 2026-02-06 | 브레이크포인트 재설계, 세로 자동 확장, v5.1 자동 줄바꿈 | [sessions/2026-02-06.md](./sessions/2026-02-06.md) | -| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) | -| 2026-02-05 저녁 | v5 통합, 그리드 가이드 재설계 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) | - ---- - -## 관련 결정 - -| ADR | 제목 | 날짜 | -|-----|------|------| -| 006 | v5.1 자동 줄바꿈 + 검토 필요 시스템 | 2026-02-06 | -| 005 | 브레이크포인트 재설계 (기기 기반) + 세로 자동 확장 | 2026-02-06 | -| 004 | 그리드 가이드 CSS Grid 통합 | 2026-02-05 | -| 003 | v5 CSS Grid 채택 | 2026-02-05 | -| 001 | v4 제약조건 기반 | 2026-02-03 | - ---- - -*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)* diff --git a/popdocs/WORKFLOW_PROMPTS.md b/popdocs/WORKFLOW_PROMPTS.md deleted file mode 100644 index 12f70a1e..00000000 --- a/popdocs/WORKFLOW_PROMPTS.md +++ /dev/null @@ -1,302 +0,0 @@ -# 워크플로우 프롬프트 - -> 각 작업 단계에서 AI에게 내리는 표준 프롬프트입니다. -> 상황에 맞는 프롬프트를 복사해서 사용하세요. -> `[괄호]` 안은 상황에 맞게 수정하세요. - ---- - -## 한 번에 복사용 - -``` -===== 토의 중 개념 학습 ===== -지금 설명한 [개념명]을 우리 프로젝트 코드에서 실제 사용되는 예시로 보여줘. -해당 코드가 없으면 어떤 문제가 생기는지 한 문장으로. - -===== 계획 ===== -구현 계획서를 작성해줘. - -포함할 것: -1. 파일별 변경 사항 (추가/수정/삭제할 코드) -2. 구현 순서 (의존성 기반) - -사전 검증 (코딩 전에 반드시): -1. 새로 추가할 변수/함수/타입 각각에 대해 해당 파일에서 Grep으로 동일 이름 검색 -2. 충돌 발견 시 "충돌: [이름] - [파일명] 라인 [X]에 기존 정의 있음" 보고 -3. 충돌 있으면 해결 방안 제시 (이름 변경 or 기존 코드 재사용) -4. 계획서에 명시된 모든 함수/변수/타입을 리스트업하고 "어디서 정의, 어디서 사용" 매핑 -5. 사용처는 있는데 정의가 누락된 항목이 있으면 보고 - -주의사항: -- 이 대화를 못 본 사람도 실행할 수 있을 정도로 구체적으로 -- 빠뜨리면 에러날 만한 함정을 명시적으로 경고해줘 - -문서 정리: -- PLAN.md "현재 구현 계획" 섹션을 이 계획으로 교체해줘 -- STATUS.md "다음 작업"도 동기화해줘 - -===== 계획 이해 (선택) ===== -이 계획에서 가장 복잡한 변경 1개를 골라서, -왜 이렇게 해야 하는지 한 문장으로 설명해줘. - -===== 코딩 ===== -위 계획대로 코딩 진행해줘. - -규칙: -1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어 -2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인 -3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해 -4. 한 파일 완료할 때마다 린트 확인 - -각 파일 수정이 끝나면 이것만 알려줘: -- 충돌 검사 결과 -- 추가한 import -- 정의한 함수/변수 -- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장) - -코딩 완료 후 자체 검증: -- 새로 추가한 모든 변수/함수가 정의되어 있는가? -- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가? -- import한 모든 것이 실제로 사용되는가? -- 사용하는 모든 것이 import되어 있는가? -- interface의 모든 props가 실제로 전달되는가? -이상 없으면 완료 보고, 이상 있으면 수정 후 보고. - -문서 정리: -- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘 - -===== 새 세션 코딩 (다른 모델) ===== -@popdocs/ 의 README → STATUS → PLAN.md "현재 구현 계획" 순서로 읽고, -계획대로 코딩을 진행해줘. - -규칙: -1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어 -2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인 -3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해 -4. 한 파일 완료할 때마다 린트 확인 - -각 파일 수정이 끝나면 이것만 알려줘: -- 충돌 검사 결과 -- 추가한 import -- 정의한 함수/변수 -- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장) - -코딩 완료 후 자체 검증: -- 새로 추가한 모든 변수/함수가 정의되어 있는가? -- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가? -- import한 모든 것이 실제로 사용되는가? -- 사용하는 모든 것이 import되어 있는가? -- interface의 모든 props가 실제로 전달되는가? -이상 없으면 완료 보고, 이상 있으면 수정 후 보고. - -문서 정리: -- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘 - -===== 검수 ===== -수정한 파일들을 검수해줘. - -검증 항목: -1. 린트 에러 -2. 새로 추가한 변수/함수가 중복 정의되지 않았는지 Grep 확인 -3. import한 것 중 사용 안 하는 것, 사용하는데 import 안 한 것 -4. interface에 정의된 props와 실제 전달되는 props 일치 여부 - -문제 발견 시: -- 고치기 전에 해당 코드를 보여주고 어디가 잘못됐는지 표시해줘 -- 내가 확인한 다음에 고쳐줘 - -문서 정리: -- 발견된 문제가 있으면 PROBLEMS.md에 추가할 내용을 미리 정리해둬 - -===== 수정 ===== -발견된 문제를 수정해줘. - -수정 전에 먼저: -1. 이 문제가 왜 발생했는지 원인 한 문장 -2. 다음에 같은 실수를 방지하려면 코딩할 때 뭘 확인했어야 하는지 한 문장 -그다음 수정 진행해. - -문서 정리: -- 수정한 내용을 PROBLEMS.md 형식(문제 | 해결 | 날짜 | 키워드)으로 정리해둬 - -===== 기록 ===== -작업 내용을 popdocs에 기록해줘. - -업데이트 대상: -- sessions/오늘날짜.md 생성 -- CHANGELOG.md 섹션 추가 -- STATUS.md 진행상태 업데이트 -- PLAN.md "현재 구현 계획"에서 완료 항목 최종 확인 -- README.md "마지막 대화 요약" 동기화 -- PROBLEMS.md에 발생한 문제-해결 추가 (있으면) -- INDEX.md에 새로 추가된 기능/함수 색인 추가 (있으면) - -추가로 "이번 작업에서 배운 것" 섹션을 포함해줘: -- 새로 알게 된 기술 개념 (있으면) -- 발생했던 에러와 원인 패턴 (있으면) -- 다음에 비슷한 작업할 때 주의할 점 (있으면) -없으면 생략. - -===== 동기화 확인 ===== -popdocs 문서 간 동기화 상태를 확인해줘. - -확인 항목: -1. README.md "마지막 대화 요약"이 STATUS.md와 일치하는지 -2. STATUS.md "다음 작업"이 PLAN.md "현재 구현 계획"과 일치하는지 -3. PLAN.md 체크박스 상태가 실제 코드 변경과 일치하는지 -4. sessions/오늘날짜.md의 "완료" 항목이 CHANGELOG.md와 일치하는지 - -불일치 발견 시: -- 어떤 문서의 어떤 부분이 다른지 보여줘 -- STATUS.md를 정본으로 맞춰줘 - -===== 주간 복습 (금요일) ===== -이번 주 작업 기록을 보고: -1. 내가 "쉽게 설명해줘"라고 요청했던 개념 중 가장 중요한 3개 -2. 발생했던 에러 중 다시 만날 가능성이 높은 패턴 2개 -3. 각각을 한 문장 정의 + 우리 프로젝트에서 어디에 해당하는지 -정리해줘. - -참조할 문서: -- sessions/ 이번 주 파일들 -- PROBLEMS.md 이번 주 항목들 -- CHANGELOG.md 이번 주 섹션들 - -===== 병합 준비 (merge 전) ===== -[source-branch]를 [target-branch]에 병합하려고 해. - -병합 전 점검해줘: -1. 양쪽 브랜치의 최근 커밋 히스토리 비교 (git log --oneline --left-right [target]...[source]) -2. 충돌 예상 파일 목록 (git merge --no-commit --no-ff [source] 후 git diff --name-only --diff-filter=U) -3. 충돌 예상 파일 중 규모가 큰 파일(500줄 이상) 식별 - 이 파일들은 특별 주의 대상 -4. 양쪽에서 동시에 수정한 파일 목록 (git diff --name-only [target]...[source]) -5. 삭제 vs 수정 충돌 가능성 (한쪽에서 삭제하고 다른 쪽에서 수정한 파일) - -점검 후 위험도를 알려줘: -- 높음: 같은 함수/컴포넌트를 양쪽에서 구조적으로 변경한 경우 -- 중간: 같은 파일이지만 다른 부분을 수정한 경우 -- 낮음: 서로 다른 파일만 수정한 경우 - -충돌 예상 파일이 있으면 각 파일별로: -- 양쪽에서 무엇을 변경했는지 한 줄 요약 -- 어떤 쪽을 기준으로 병합해야 하는지 판단 근거 - -===== 병합 실행 (merge 중) ===== -병합을 진행해줘. - -규칙: -1. diff3 형식으로 충돌 표시 (git config merge.conflictstyle diff3) -2. 충돌 파일 하나씩 순서대로 해결 - 의존성 낮은 파일부터 -3. 각 충돌 파일 해결 전에 반드시: - - 공통 조상(base)을 확인하여 양쪽이 원래 코드에서 무엇을 변경했는지 파악 - - 양쪽 변경의 의도를 모두 보존할 수 있는지 판단 - - 한쪽만 선택해야 하면 그 이유를 명시 -4. 충돌 마커(<<<<<<, ======, >>>>>>)가 모두 제거되었는지 확인 - -각 충돌 파일 해결 후 보고: -- 충돌 위치 (함수명/컴포넌트명) -- 해결 방식: "양쪽 통합" / "ours 선택" / "theirs 선택" / "새로 작성" -- 선택 이유 한 문장 - -===== 병합 후 시맨틱 검증 (merge 후 - 가장 중요) ===== -텍스트 충돌은 해결했지만, Git이 감지 못하는 시맨틱 충돌을 점검해줘. - -검증 항목: -1. 함수/변수 이름 변경 충돌: 한쪽에서 rename한 함수를 다른 쪽에서 기존 이름으로 호출하고 있지 않은지 -2. 타입/인터페이스 변경 충돌: 타입 필드가 변경/삭제되었는데 다른 쪽에서 해당 필드를 사용하는 코드가 추가되지 않았는지 -3. import 정합성: 병합 후 중복 import, 누락 import, 사용하지 않는 import 확인 -4. 함수 시그니처 충돌: 매개변수가 변경되었는데 호출부가 기존 시그니처를 사용하지 않는지 -5. 삭제된 코드 의존성: 한쪽에서 삭제한 함수/변수를 다른 쪽 새 코드가 참조하지 않는지 -6. 전역 상태/설정 변경: 설정값이 바뀌었는데 기존 값 기반 로직이 추가되지 않았는지 - -검증 방법: -- TypeScript 타입 체크: npx tsc --noEmit -- 빌드 확인: npm run build -- 남은 충돌 마커: git diff --check -- 병합으로 변경된 전체 diff: git diff HEAD~1..HEAD - -문제 발견 시: -- 파일명, 라인, 구체적인 문제를 보여줘 -- 수정 방안을 제시하되, 내 확인 후에 수정해줘 - -===== 병합 후 빌드/테스트 검증 ===== -병합 후 프로젝트가 정상 작동하는지 확인해줘. - -순서: -1. 남은 충돌 마커 검색: 프로젝트 전체에서 <<<<<<, ======, >>>>>> 검색 -2. TypeScript 컴파일: npx tsc --noEmit → 타입 에러 목록 -3. 프론트엔드 빌드: npm run build → 빌드 에러 목록 -4. 백엔드 빌드: npm run build (backend-node) → 빌드 에러 목록 -5. 린트 체크: 변경된 파일들에 대해 린트 확인 - -에러 발견 시 각각에 대해: -- 에러 메시지 전문 -- 원인이 병합 때문인지, 기존 코드 문제인지 구분 -- 병합 때문이면 어떤 충돌 해결이 잘못되었는지 추적 - -===== 병합 완료 정리 ===== -병합이 완료되었어. 정리해줘. - -정리 항목: -1. 병합 요약: 어떤 브랜치에서 어떤 브랜치로, 총 충돌 파일 수, 해결 방식 통계 -2. 주의가 필요한 변경사항: 시맨틱 충돌 위험이 있었던 부분 목록 -3. 테스트가 필요한 기능: 병합으로 영향받은 기능 목록 (수동 테스트 대상) -4. 커밋 메시지 작성: 병합 내용을 요약한 적절한 커밋 메시지 제안 - -문서 정리: -- PROBLEMS.md에 병합 중 발견된 문제-해결 추가 (있으면) -- CHANGELOG.md에 병합 내용 기록 -``` - ---- - -## popdocs 업데이트 시점 요약 - -| 단계 | 업데이트 대상 | 시점 | -|------|-------------|------| -| 계획 수립 | PLAN.md "현재 구현 계획", STATUS.md | 계획 확정 시 | -| 코딩 중 | PLAN.md 완료 체크 `[x]` | 각 파일 완료 시 | -| 검수 | PROBLEMS.md 내용 준비 | 문제 발견 시 | -| 수정 | PROBLEMS.md 내용 준비 | 수정 완료 시 | -| 병합 준비 | (응답으로 제공) | merge 시작 전 | -| 병합 실행 | (충돌 해결 중) | merge 진행 중 | -| 병합 시맨틱 검증 | (응답으로 제공) | 텍스트 충돌 해결 직후 | -| 병합 빌드 검증 | (응답으로 제공) | 시맨틱 검증 후 | -| 병합 완료 정리 | PROBLEMS.md, CHANGELOG.md | 병합 최종 완료 시 | -| 기록 | sessions/, CHANGELOG, STATUS, README, PROBLEMS, INDEX | 작업 완료 시 | -| 동기화 확인 | 전체 문서 간 불일치 점검 | 기록 직후 | -| 주간 복습 | (응답으로 제공, 파일 저장은 선택) | 금요일 | - ---- - -## 세션 분리 가이드 - -``` -[Opus 세션] 토의 + 계획 - → popdocs 업데이트 (PLAN.md, STATUS.md) - → 세션 종료 - -[새 세션 - Sonnet/Opus] 코딩 + 검수 + 수정 - → "@popdocs/ 읽고 PLAN.md 계획대로 진행해" - → 15건 이내로 완료 - → 세션 종료 - -[새 세션 - 아무 모델] 기록 + 동기화 확인 - → "기록" 프롬프트 → "동기화 확인" 프롬프트 - -[병합 세션 - Opus 권장] 브랜치 병합 - → "병합 준비" 프롬프트 → 위험도 파악 - → "병합 실행" 프롬프트 → 텍스트 충돌 해결 - → "병합 후 시맨틱 검증" 프롬프트 → 숨은 버그 점검 - → "병합 후 빌드/테스트 검증" 프롬프트 → 빌드 확인 - → "병합 완료 정리" 프롬프트 → 기록 및 커밋 -``` - -**세션을 끊는 기준**: -- 작업이 15건 이내로 끝나면 한 세션에서 끝까지 (끊을 필요 없음) -- 대화가 15건을 넘어갈 것 같으면 세션 분리 -- 완전히 다른 작업으로 전환할 때 - ---- - -*최종 업데이트: 2026-02-09* diff --git a/popdocs/archive/BUGFIX_CANVAS_ROWS.md b/popdocs/archive/BUGFIX_CANVAS_ROWS.md deleted file mode 100644 index 713edce6..00000000 --- a/popdocs/archive/BUGFIX_CANVAS_ROWS.md +++ /dev/null @@ -1,227 +0,0 @@ -# POP 레이아웃 canvasGrid.rows 버그 수정 - -## 문제점 - -### 1. 데이터 불일치 -- **DB에 저장된 데이터**: `canvasGrid.rowHeight: 20` (고정 픽셀) -- **코드에서 기대하는 데이터**: `canvasGrid.rows: 24` (비율 기반) -- **결과**: `rows`가 `undefined`로 인한 렌더링 오류 - -### 2. 타입 정의 불일치 -- **PopCanvas.tsx 타입**: `{ columns: number; rowHeight: number; gap: number }` -- **실제 사용**: `canvasGrid.rows`로 계산 -- **결과**: 타입 안정성 저하 - -### 3. 렌더링 오류 -- **디자이너**: `rowHeight = resolution.height / undefined` → `NaN` -- **뷰어**: `gridTemplateRows: repeat(undefined, 1fr)` → CSS 무효 -- **결과**: 섹션이 매우 작게 표시됨 - ---- - -## 수정 내용 - -### 1. ensureV2Layout 강화 -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -```typescript -export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => { - let result: PopLayoutDataV2; - - if (isV2Layout(data)) { - result = data; - } else if (isV1Layout(data)) { - result = migrateV1ToV2(data); - } else { - console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성"); - result = createEmptyPopLayoutV2(); - } - - // ✅ canvasGrid.rows 보장 (구버전 데이터 호환) - if (!result.settings.canvasGrid.rows) { - console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); - result.settings.canvasGrid = { - ...result.settings.canvasGrid, - rows: DEFAULT_CANVAS_GRID.rows, // 24 - }; - } - - return result; -}; -``` - -**효과**: DB에서 로드한 구버전 데이터도 자동으로 `rows: 24` 보장 - ---- - -### 2. PopCanvas.tsx 타입 수정 및 fallback -**파일**: `frontend/components/pop/designer/PopCanvas.tsx` - -**타입 정의 수정**: -```typescript -interface DeviceFrameProps { - canvasGrid: { columns: number; rows: number; gap: number }; // rowHeight → rows - // ... -} -``` - -**fallback 추가**: -```typescript -// ✅ rows가 없으면 24 사용 -const rows = canvasGrid.rows || 24; -const rowHeight = Math.floor(resolution.height / rows); -``` - -**효과**: -- 타입 일관성 확보 -- `NaN` 방지 - ---- - -### 3. PopLayoutRenderer.tsx fallback -**파일**: `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` - -```typescript -style={{ - display: "grid", - gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`, - // ✅ fallback 추가 - gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`, - gap: `${canvasGrid.gap}px`, - padding: `${canvasGrid.gap}px`, -}} -``` - -**효과**: 뷰어에서도 안전하게 렌더링 - ---- - -### 4. 백엔드 저장 로직 강화 -**파일**: `backend-node/src/services/screenManagementService.ts` - -```typescript -if (isV2) { - dataToSave = { - ...layoutData, - version: "pop-2.0", - }; - - // ✅ canvasGrid.rows 검증 및 보정 - if (dataToSave.settings?.canvasGrid) { - if (!dataToSave.settings.canvasGrid.rows) { - console.warn("canvasGrid.rows 없음, 기본값 24로 설정"); - dataToSave.settings.canvasGrid.rows = 24; - } - // ✅ 구버전 rowHeight 필드 제거 - if (dataToSave.settings.canvasGrid.rowHeight) { - console.warn("구버전 rowHeight 필드 제거"); - delete dataToSave.settings.canvasGrid.rowHeight; - } - } -} -``` - -**효과**: 앞으로 저장되는 모든 데이터는 올바른 구조 보장 - ---- - -## 원칙 준수 여부 - -### 1. 데스크톱과 완전 분리 ✅ -- POP 전용 파일만 수정 -- 데스크톱 코드 0% 영향 - -### 2. 4모드 반응형 디자인 ✅ -- 변경 없음 - -### 3. 비율 기반 그리드 시스템 ✅ -- **오히려 원칙을 바로잡는 수정** -- 고정 픽셀(`rowHeight`) → 비율(`rows`) 강제 - ---- - -## 해결된 문제 - -| 문제 | 수정 전 | 수정 후 | -|------|---------|---------| -| 섹션 크기 | 매우 작게 표시 | 정상 크기 (24x24 그리드) | -| 디자이너 렌더링 | `NaN` 오류 | 정상 계산 | -| 뷰어 렌더링 | CSS 무효 | 비율 기반 렌더링 | -| 타입 안정성 | `rowHeight` vs `rows` 불일치 | `rows`로 통일 | -| 구버전 데이터 | 호환 불가 | 자동 보정 | - ---- - -## 테스트 방법 - -### 1. 기존 화면 확인 (screen_id: 3884) -```bash -# 디자이너 접속 -http://localhost:9771/screen-management/pop-designer/3884 - -# 저장 후 뷰어 확인 -http://localhost:9771/pop/screens/3884 -``` - -**기대 결과**: -- 섹션이 화면 전체 크기로 정상 표시 -- 가로/세로 모드 전환 시 비율 유지 - -### 2. 새로운 화면 생성 -- POP 디자이너에서 새 화면 생성 -- 섹션 추가 및 배치 -- 저장 후 DB 확인 - -**DB 확인**: -```sql -SELECT - screen_id, - layout_data->'settings'->'canvasGrid' as canvas_grid -FROM screen_layouts_pop -WHERE screen_id = 3884; -``` - -**기대 결과**: -```json -{ - "gap": 4, - "rows": 24, - "columns": 24 -} -``` - ---- - -## 추가 조치 사항 - -### 1. 기존 DB 데이터 마이그레이션 (선택) -만약 프론트엔드 자동 보정이 아닌 DB 마이그레이션을 원한다면: - -```sql -UPDATE screen_layouts_pop -SET layout_data = jsonb_set( - jsonb_set( - layout_data, - '{settings,canvasGrid,rows}', - '24' - ), - '{settings,canvasGrid}', - (layout_data->'settings'->'canvasGrid') - 'rowHeight' -) -WHERE layout_data->'settings'->'canvasGrid'->>'rows' IS NULL - AND layout_data->>'version' = 'pop-2.0'; -``` - -### 2. 모드별 컴포넌트 위치 반대 문제 -**별도 이슈**: `activeModeKey` 상태 관리 점검 필요 -- DeviceFrame 클릭 시 모드 전환 -- 저장 시 올바른 `modeKey` 전달 확인 - ---- - -## 결론 - -✅ **원칙 준수**: 데스크톱 분리, 4모드 반응형 유지 -✅ **비율 기반 강제**: 고정 픽셀 제거 -✅ **하위 호환**: 구버전 데이터 자동 보정 -✅ **안정성 향상**: 타입 일관성 확보 diff --git a/popdocs/archive/COMPONENT_ROADMAP.md b/popdocs/archive/COMPONENT_ROADMAP.md deleted file mode 100644 index 99c0d616..00000000 --- a/popdocs/archive/COMPONENT_ROADMAP.md +++ /dev/null @@ -1,389 +0,0 @@ -# POP 컴포넌트 로드맵 - -## 큰 그림: 3단계 접근 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ │ -│ 1단계: 기초 블록 2단계: 조합 블록 3단계: 완성 화면 │ -│ ─────────────── ─────────────── ─────────────── │ -│ │ -│ [버튼] [입력창] [폼 그룹] [작업지시 화면] │ -│ [아이콘] [라벨] → [카드] → [실적입력 화면] │ -│ [뱃지] [로딩] [리스트] [모니터링 대시보드] │ -│ [테이블] │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 1단계: 기초 블록 (Primitive) - -가장 작은 단위. 다른 곳에서 재사용됩니다. - -### 필수 기초 블록 - -| 컴포넌트 | 역할 | 우선순위 | -|---------|------|---------| -| `PopButton` | 모든 버튼 | 1 | -| `PopInput` | 텍스트 입력 | 1 | -| `PopLabel` | 라벨/제목 | 1 | -| `PopIcon` | 아이콘 표시 | 1 | -| `PopBadge` | 상태 뱃지 | 2 | -| `PopLoading` | 로딩 스피너 | 2 | -| `PopDivider` | 구분선 | 3 | - -### PopButton 예시 - -```typescript -interface PopButtonProps { - children: React.ReactNode; - variant: "primary" | "secondary" | "danger" | "success"; - size: "sm" | "md" | "lg" | "xl"; - disabled?: boolean; - loading?: boolean; - icon?: string; - fullWidth?: boolean; - onClick?: () => void; -} - -// 사용 - - 작업 완료 - -``` - -### PopInput 예시 - -```typescript -interface PopInputProps { - type: "text" | "number" | "date" | "time"; - value: string | number; - onChange: (value: string | number) => void; - label?: string; - placeholder?: string; - required?: boolean; - error?: string; - size: "md" | "lg"; // POP은 lg 기본 -} - -// 사용 - -``` - ---- - -## 2단계: 조합 블록 (Compound) - -기초 블록을 조합한 중간 단위. - -### 조합 블록 목록 - -| 컴포넌트 | 구성 | 용도 | -|---------|------|-----| -| `PopFormField` | Label + Input + Error | 폼 입력 그룹 | -| `PopCard` | Container + Header + Body | 정보 카드 | -| `PopListItem` | Container + Content + Action | 리스트 항목 | -| `PopNumberPad` | Grid + Buttons | 숫자 입력 | -| `PopStatusBox` | Icon + Label + Value | 상태 표시 | - -### PopFormField 예시 - -```typescript -// 기초 블록 조합 -function PopFormField({ label, required, error, children }) { - return ( -
- {label} - {children} - {error && {error}} -
- ); -} - -// 사용 - - - -``` - -### PopCard 예시 - -```typescript -function PopCard({ title, badge, children, onClick }) { - return ( -
-
- {title} - {badge && {badge}} -
-
- {children} -
-
- ); -} - -// 사용 - -

목표 수량: 100개

-

완료 수량: 45개

-
-``` - ---- - -## 3단계: 복합 컴포넌트 (Complex) - -비즈니스 로직이 포함된 완성형. - -### 복합 컴포넌트 목록 - -| 컴포넌트 | 기능 | 데이터 | -|---------|------|-------| -| `PopDataTable` | 대량 데이터 표시/편집 | API 연동 | -| `PopCardList` | 카드 형태 리스트 | API 연동 | -| `PopBarcodeScanner` | 바코드/QR 스캔 | 카메라/외부장치 | -| `PopKpiGauge` | KPI 게이지 | 실시간 데이터 | -| `PopAlarmList` | 알람 목록 | 웹소켓 | -| `PopProcessFlow` | 공정 흐름도 | 공정 데이터 | - -### PopDataTable 예시 - -```typescript -interface PopDataTableProps { - // 데이터 - data: any[]; - columns: Column[]; - - // 기능 - selectable?: boolean; - editable?: boolean; - sortable?: boolean; - - // 반응형 (자동) - responsiveColumns?: { - tablet: string[]; - mobile: string[]; - }; - - // 이벤트 - onRowClick?: (row: any) => void; - onSelectionChange?: (selected: any[]) => void; -} - -// 사용 - openDetail(row.id)} -/> -``` - ---- - -## 개발 순서 제안 - -### Phase 1: 기초 (1-2주) - -``` -Week 1: -- PopButton (모든 버튼의 기반) -- PopInput (모든 입력의 기반) -- PopLabel -- PopIcon - -Week 2: -- PopBadge -- PopLoading -- PopDivider -``` - -### Phase 2: 조합 (2-3주) - -``` -Week 3: -- PopFormField (폼의 기본 단위) -- PopCard (카드의 기본 단위) - -Week 4: -- PopListItem -- PopStatusBox -- PopNumberPad - -Week 5: -- PopModal -- PopToast -``` - -### Phase 3: 복합 (3-4주) - -``` -Week 6-7: -- PopDataTable (가장 복잡) -- PopCardList - -Week 8-9: -- PopBarcodeScanner -- PopKpiGauge -- PopAlarmList -- PopProcessFlow -``` - ---- - -## 컴포넌트 설계 원칙 - -### 1. 크기는 외부에서 제어 - -```typescript -// 좋음: 크기를 props로 받음 -확인 - -// 나쁨: 내부에서 크기 고정 - -``` - -### 2. 최소 크기는 내부에서 보장 - -```typescript -// 컴포넌트 내부 -const styles = { - minHeight: 48, // 터치 최소 크기 보장 - minWidth: 80, -}; -``` - -### 3. 반응형은 자동 - -```typescript -// 좋음: 화면 크기에 따라 자동 조절 - - - - -// 나쁨: 모드별로 다른 컴포넌트 -{isMobile ? : } -``` - -### 4. 데이터와 UI 분리 - -```typescript -// 좋음: 데이터 로직은 훅으로 -const { data, loading, error } = useWorkOrders(); - - - -// 나쁨: 컴포넌트 안에서 fetch -function PopDataTable() { - useEffect(() => { - fetch('/api/work-orders')... - }, []); -} -``` - ---- - -## 폴더 구조 제안 - -``` -frontend/components/pop/ -├── primitives/ # 1단계: 기초 블록 -│ ├── PopButton.tsx -│ ├── PopInput.tsx -│ ├── PopLabel.tsx -│ ├── PopIcon.tsx -│ ├── PopBadge.tsx -│ ├── PopLoading.tsx -│ └── index.ts -│ -├── compounds/ # 2단계: 조합 블록 -│ ├── PopFormField.tsx -│ ├── PopCard.tsx -│ ├── PopListItem.tsx -│ ├── PopNumberPad.tsx -│ ├── PopStatusBox.tsx -│ └── index.ts -│ -├── complex/ # 3단계: 복합 컴포넌트 -│ ├── PopDataTable/ -│ │ ├── PopDataTable.tsx -│ │ ├── PopTableHeader.tsx -│ │ ├── PopTableRow.tsx -│ │ └── index.ts -│ ├── PopCardList/ -│ ├── PopBarcodeScanner/ -│ └── index.ts -│ -├── hooks/ # 공용 훅 -│ ├── usePopTheme.ts -│ ├── useResponsiveSize.ts -│ └── useTouchFeedback.ts -│ -└── styles/ # 공용 스타일 - ├── pop-variables.css - └── pop-base.css -``` - ---- - -## 스타일 변수 - -```css -/* pop-variables.css */ - -:root { - /* 터치 크기 */ - --pop-touch-min: 48px; - --pop-touch-industrial: 60px; - - /* 폰트 크기 */ - --pop-font-body: clamp(14px, 1.5vw, 18px); - --pop-font-heading: clamp(18px, 2.5vw, 28px); - --pop-font-caption: clamp(12px, 1vw, 14px); - - /* 간격 */ - --pop-gap-sm: 8px; - --pop-gap-md: 16px; - --pop-gap-lg: 24px; - - /* 색상 */ - --pop-primary: #2563eb; - --pop-success: #16a34a; - --pop-warning: #f59e0b; - --pop-danger: #dc2626; - - /* 고대비 (야외용) */ - --pop-high-contrast-bg: #000000; - --pop-high-contrast-fg: #ffffff; -} -``` - ---- - -## 다음 단계 - -1. **기초 블록부터 시작**: PopButton, PopInput 먼저 만들기 -2. **스토리북 설정**: 컴포넌트별 문서화 -3. **테스트**: 터치 크기, 반응형 확인 -4. **디자이너 연동**: v4 레이아웃 시스템과 통합 - ---- - -*최종 업데이트: 2026-02-03* diff --git a/popdocs/archive/GRID_CODING_PLAN.md b/popdocs/archive/GRID_CODING_PLAN.md deleted file mode 100644 index 60760e96..00000000 --- a/popdocs/archive/GRID_CODING_PLAN.md +++ /dev/null @@ -1,763 +0,0 @@ -# POP 그리드 시스템 코딩 계획 - -> 작성일: 2026-02-05 -> 상태: 코딩 준비 완료 - ---- - -## 작업 목록 - -``` -Phase 5.1: 타입 정의 ───────────────────────────── - [ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등) - [ ] 2. 브레이크포인트 상수 정의 - [ ] 3. v5 생성/변환 함수 - -Phase 5.2: 그리드 렌더러 ───────────────────────── - [ ] 4. PopGridRenderer.tsx 생성 - [ ] 5. 위치 변환 로직 (12칸→4칸) - -Phase 5.3: 디자이너 UI ─────────────────────────── - [ ] 6. PopCanvasV5.tsx 생성 - [ ] 7. 드래그 스냅 기능 - [ ] 8. ComponentEditorPanelV5.tsx - -Phase 5.4: 통합 ────────────────────────────────── - [ ] 9. 자동 변환 알고리즘 - [ ] 10. PopDesigner.tsx 통합 -``` - ---- - -## Phase 5.1: 타입 정의 - -### 작업 1: v5 타입 정의 - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -**추가할 코드**: - -```typescript -// ======================================== -// v5.0 그리드 기반 레이아웃 -// ======================================== -// 핵심: CSS Grid로 정확한 위치 지정 -// - 열/행 좌표로 배치 (col, row) -// - 칸 단위 크기 (colSpan, rowSpan) - -/** - * v5 레이아웃 (그리드 기반) - */ -export interface PopLayoutDataV5 { - version: "pop-5.0"; - - // 그리드 설정 - gridConfig: PopGridConfig; - - // 컴포넌트 정의 (ID → 정의) - components: Record; - - // 데이터 흐름 (기존과 동일) - dataFlow: PopDataFlow; - - // 전역 설정 - settings: PopGlobalSettingsV5; - - // 메타데이터 - metadata?: PopLayoutMetadata; - - // 모드별 오버라이드 (위치 변경용) - overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; - }; -} - -/** - * 그리드 설정 - */ -export interface PopGridConfig { - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px - - // 간격 (px) - gap: number; // 기본 8px - - // 패딩 (px) - padding: number; // 기본 16px -} - -/** - * v5 컴포넌트 정의 - */ -export interface PopComponentDefinitionV5 { - id: string; - type: PopComponentType; - label?: string; - - // 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준 - position: PopGridPosition; - - // 모드별 표시/숨김 - visibility?: { - tablet_landscape?: boolean; - tablet_portrait?: boolean; - mobile_landscape?: boolean; - mobile_portrait?: boolean; - }; - - // 기존 속성 - dataBinding?: PopDataBinding; - style?: PopStylePreset; - config?: PopComponentConfig; -} - -/** - * 그리드 위치 - */ -export interface PopGridPosition { - col: number; // 시작 열 (1부터, 최대 12) - row: number; // 시작 행 (1부터) - colSpan: number; // 차지할 열 수 (1~12) - rowSpan: number; // 차지할 행 수 (1~) -} - -/** - * v5 전역 설정 - */ -export interface PopGlobalSettingsV5 { - // 터치 최소 크기 (px) - touchTargetMin: number; // 기본 48 - - // 모드 - mode: "normal" | "industrial"; -} - -/** - * v5 모드별 오버라이드 - */ -export interface PopModeOverrideV5 { - // 컴포넌트별 위치 오버라이드 - positions?: Record>; - - // 컴포넌트별 숨김 - hidden?: string[]; -} -``` - -### 작업 2: 브레이크포인트 상수 - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -```typescript -// ======================================== -// 그리드 브레이크포인트 -// ======================================== - -export type GridMode = - | "mobile_portrait" - | "mobile_landscape" - | "tablet_portrait" - | "tablet_landscape"; - -export const GRID_BREAKPOINTS: Record = { - // 4~6인치 모바일 세로 - mobile_portrait: { - maxWidth: 599, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - label: "모바일 세로 (4칸)", - }, - - // 6~8인치 모바일 가로 / 작은 태블릿 - mobile_landscape: { - minWidth: 600, - maxWidth: 839, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - label: "모바일 가로 (6칸)", - }, - - // 8~10인치 태블릿 세로 - tablet_portrait: { - minWidth: 840, - maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - label: "태블릿 세로 (8칸)", - }, - - // 10~14인치 태블릿 가로 (기본) - tablet_landscape: { - minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - label: "태블릿 가로 (12칸)", - }, -}; - -// 기본 모드 -export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; - -// 뷰포트 너비로 모드 감지 -export function detectGridMode(viewportWidth: number): GridMode { - if (viewportWidth < 600) return "mobile_portrait"; - if (viewportWidth < 840) return "mobile_landscape"; - if (viewportWidth < 1024) return "tablet_portrait"; - return "tablet_landscape"; -} -``` - -### 작업 3: v5 생성/변환 함수 - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -```typescript -// ======================================== -// v5 유틸리티 함수 -// ======================================== - -/** - * 빈 v5 레이아웃 생성 - */ -export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ - version: "pop-5.0", - gridConfig: { - rowHeight: 48, - gap: 8, - padding: 16, - }, - components: {}, - dataFlow: { connections: [] }, - settings: { - touchTargetMin: 48, - mode: "normal", - }, -}); - -/** - * v5 레이아웃 여부 확인 - */ -export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { - return layout?.version === "pop-5.0"; -}; - -/** - * v5 컴포넌트 정의 생성 - */ -export const createComponentDefinitionV5 = ( - id: string, - type: PopComponentType, - position: PopGridPosition, - label?: string -): PopComponentDefinitionV5 => ({ - id, - type, - label, - position, -}); - -/** - * 컴포넌트 타입별 기본 크기 (칸 단위) - */ -export const DEFAULT_COMPONENT_SIZE: Record = { - "pop-field": { colSpan: 3, rowSpan: 1 }, - "pop-button": { colSpan: 2, rowSpan: 1 }, - "pop-list": { colSpan: 12, rowSpan: 4 }, - "pop-indicator": { colSpan: 3, rowSpan: 2 }, - "pop-scanner": { colSpan: 6, rowSpan: 2 }, - "pop-numpad": { colSpan: 4, rowSpan: 5 }, - "pop-spacer": { colSpan: 1, rowSpan: 1 }, - "pop-break": { colSpan: 12, rowSpan: 0 }, -}; - -/** - * v4 → v5 마이그레이션 - */ -export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => { - const componentsV4 = Object.values(layoutV4.components); - const componentsV5: Record = {}; - - // Flexbox 순서 → Grid 위치 변환 - let currentRow = 1; - let currentCol = 1; - const columns = 12; - - componentsV4.forEach((comp) => { - // 픽셀 → 칸 변환 (대략적) - const colSpan = comp.size.width === "fill" - ? columns - : Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85))); - const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48)); - - // 줄바꿈 체크 - if (currentCol + colSpan - 1 > columns) { - currentRow += 1; - currentCol = 1; - } - - componentsV5[comp.id] = { - id: comp.id, - type: comp.type, - label: comp.label, - position: { - col: currentCol, - row: currentRow, - colSpan, - rowSpan, - }, - visibility: comp.visibility, - dataBinding: comp.dataBinding, - config: comp.config, - }; - - currentCol += colSpan; - }); - - return { - version: "pop-5.0", - gridConfig: { - rowHeight: 48, - gap: layoutV4.settings.defaultGap, - padding: layoutV4.settings.defaultPadding, - }, - components: componentsV5, - dataFlow: layoutV4.dataFlow, - settings: { - touchTargetMin: layoutV4.settings.touchTargetMin, - mode: layoutV4.settings.mode, - }, - }; -}; -``` - ---- - -## Phase 5.2: 그리드 렌더러 - -### 작업 4: PopGridRenderer.tsx - -**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx` - -```typescript -"use client"; - -import React, { useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { - PopLayoutDataV5, - PopComponentDefinitionV5, - PopGridPosition, - GridMode, - GRID_BREAKPOINTS, - detectGridMode, -} from "../types/pop-layout"; - -interface PopGridRendererProps { - layout: PopLayoutDataV5; - viewportWidth: number; - currentMode?: GridMode; - isDesignMode?: boolean; - selectedComponentId?: string | null; - onComponentClick?: (componentId: string) => void; - onBackgroundClick?: () => void; - className?: string; -} - -export function PopGridRenderer({ - layout, - viewportWidth, - currentMode, - isDesignMode = false, - selectedComponentId, - onComponentClick, - onBackgroundClick, - className, -}: PopGridRendererProps) { - const { gridConfig, components, overrides } = layout; - - // 현재 모드 (자동 감지 또는 지정) - const mode = currentMode || detectGridMode(viewportWidth); - const breakpoint = GRID_BREAKPOINTS[mode]; - - // CSS Grid 스타일 - const gridStyle = useMemo((): React.CSSProperties => ({ - display: "grid", - gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, - gridAutoRows: `${breakpoint.rowHeight}px`, - gap: `${breakpoint.gap}px`, - padding: `${breakpoint.padding}px`, - minHeight: "100%", - }), [breakpoint]); - - // visibility 체크 - const isVisible = (comp: PopComponentDefinitionV5): boolean => { - if (!comp.visibility) return true; - return comp.visibility[mode] !== false; - }; - - // 위치 변환 (12칸 기준 → 현재 모드 칸 수) - const convertPosition = (position: PopGridPosition): React.CSSProperties => { - const sourceColumns = 12; // 항상 12칸 기준으로 저장 - const targetColumns = breakpoint.columns; - - if (sourceColumns === targetColumns) { - return { - gridColumn: `${position.col} / span ${position.colSpan}`, - gridRow: `${position.row} / span ${position.rowSpan}`, - }; - } - - // 비율 계산 - const ratio = targetColumns / sourceColumns; - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`, - gridRow: `${position.row} / span ${position.rowSpan}`, - }; - }; - - // 오버라이드 적용 - const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { - const override = overrides?.[mode]?.positions?.[comp.id]; - if (override) { - return { ...comp.position, ...override }; - } - return comp.position; - }; - - return ( -
{ - if (e.target === e.currentTarget) { - onBackgroundClick?.(); - } - }} - > - {Object.values(components).map((comp) => { - if (!isVisible(comp)) return null; - - const position = getEffectivePosition(comp); - const positionStyle = convertPosition(position); - - return ( -
{ - e.stopPropagation(); - onComponentClick?.(comp.id); - }} - > - {/* 컴포넌트 내용 */} - -
- ); - })} -
- ); -} - -// 컴포넌트 내용 렌더링 -function ComponentContent({ - component, - isDesignMode -}: { - component: PopComponentDefinitionV5; - isDesignMode: boolean; -}) { - const typeLabels: Record = { - "pop-field": "필드", - "pop-button": "버튼", - "pop-list": "리스트", - "pop-indicator": "인디케이터", - "pop-scanner": "스캐너", - "pop-numpad": "숫자패드", - "pop-spacer": "스페이서", - "pop-break": "줄바꿈", - }; - - if (isDesignMode) { - return ( -
-
- - {component.label || typeLabels[component.type] || component.type} - -
-
- - {typeLabels[component.type]} - -
-
- ); - } - - // 실제 컴포넌트 렌더링 (Phase 4에서 구현) - return ( -
- - {component.label || typeLabels[component.type]} - -
- ); -} - -export default PopGridRenderer; -``` - -### 작업 5: 위치 변환 유틸리티 - -**파일**: `frontend/components/pop/designer/utils/gridUtils.ts` - -```typescript -import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout"; - -/** - * 12칸 기준 위치를 다른 모드로 변환 - */ -export function convertPositionToMode( - position: PopGridPosition, - targetMode: GridMode -): PopGridPosition { - const sourceColumns = 12; - const targetColumns = GRID_BREAKPOINTS[targetMode].columns; - - if (sourceColumns === targetColumns) { - return position; - } - - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - - return { - col: newCol, - row: position.row, - colSpan: Math.max(1, newColSpan), - rowSpan: position.rowSpan, - }; -} - -/** - * 두 위치가 겹치는지 확인 - */ -export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { - // 열 겹침 - const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col); - // 행 겹침 - const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row); - - return colOverlap && rowOverlap; -} - -/** - * 겹침 해결 (아래로 밀기) - */ -export function resolveOverlaps( - positions: Array<{ id: string; position: PopGridPosition }>, - columns: number -): Array<{ id: string; position: PopGridPosition }> { - // row, col 순으로 정렬 - const sorted = [...positions].sort((a, b) => - a.position.row - b.position.row || a.position.col - b.position.col - ); - - const resolved: Array<{ id: string; position: PopGridPosition }> = []; - - sorted.forEach((item) => { - let { row, col, colSpan, rowSpan } = item.position; - - // 기존 배치와 겹치면 아래로 이동 - let attempts = 0; - while (attempts < 100) { - const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; - const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); - - if (!hasOverlap) break; - - row++; - attempts++; - } - - resolved.push({ - id: item.id, - position: { col, row, colSpan, rowSpan }, - }); - }); - - return resolved; -} - -/** - * 마우스 좌표 → 그리드 좌표 변환 - */ -export function mouseToGridPosition( - mouseX: number, - mouseY: number, - canvasRect: DOMRect, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { col: number; row: number } { - // 캔버스 내 상대 위치 - const relX = mouseX - canvasRect.left - padding; - const relY = mouseY - canvasRect.top - padding; - - // 칸 너비 계산 - const totalGap = gap * (columns - 1); - const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; - - // 그리드 좌표 계산 (1부터 시작) - const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); - - return { col, row }; -} - -/** - * 그리드 좌표 → 픽셀 좌표 변환 - */ -export function gridToPixelPosition( - col: number, - row: number, - canvasWidth: number, - columns: number, - rowHeight: number, - gap: number, - padding: number -): { x: number; y: number; width: number; height: number } { - const totalGap = gap * (columns - 1); - const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; - - return { - x: padding + (col - 1) * (colWidth + gap), - y: padding + (row - 1) * (rowHeight + gap), - width: colWidth, - height: rowHeight, - }; -} -``` - ---- - -## Phase 5.3: 디자이너 UI - -### 작업 6-7: PopCanvasV5.tsx - -**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx` - -핵심 기능: -- 그리드 배경 표시 (바둑판) -- 4개 모드 프리셋 버튼 -- 드래그 앤 드롭 (칸에 스냅) -- 컴포넌트 리사이즈 (칸 단위) - -### 작업 8: ComponentEditorPanelV5.tsx - -**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx` - -핵심 기능: -- 위치 편집 (col, row 입력) -- 크기 편집 (colSpan, rowSpan 입력) -- visibility 체크박스 - ---- - -## Phase 5.4: 통합 - -### 작업 9: 자동 변환 알고리즘 - -이미 `gridUtils.ts`에 포함 - -### 작업 10: PopDesigner.tsx 통합 - -**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx` - -변경 사항: -- v5 레이아웃 상태 추가 -- v3/v4/v5 자동 판별 -- 새 화면 → v5로 시작 -- v4 → v5 마이그레이션 옵션 - ---- - -## 파일 목록 - -| 상태 | 파일 | 작업 | -|------|------|------| -| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 | -| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 | -| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 | -| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 | -| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 | -| 수정 | `PopDesigner.tsx` | v5 통합 | - ---- - -## 시작 순서 - -``` -1. pop-layout.ts에 v5 타입 추가 (작업 1-3) - ↓ -2. PopGridRenderer.tsx 생성 (작업 4) - ↓ -3. gridUtils.ts 생성 (작업 5) - ↓ -4. PopCanvasV5.tsx 생성 (작업 6-7) - ↓ -5. ComponentEditorPanelV5.tsx 생성 (작업 8) - ↓ -6. PopDesigner.tsx 수정 (작업 9-10) - ↓ -7. 테스트 -``` - ---- - -*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)* diff --git a/popdocs/archive/GRID_SYSTEM_DESIGN.md b/popdocs/archive/GRID_SYSTEM_DESIGN.md deleted file mode 100644 index 7203763c..00000000 --- a/popdocs/archive/GRID_SYSTEM_DESIGN.md +++ /dev/null @@ -1,329 +0,0 @@ -# POP 화면 그리드 시스템 설계 - -> 작성일: 2026-02-05 -> 상태: 계획 (Plan) -> 관련: Softr, Ant Design, Material Design 분석 기반 - ---- - -## 1. 목적 - -POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계 - -### 현재 문제 -- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐 -- 컴포넌트 크기 규칙 없음 → 디자인 불일치 -- 반응형 규칙 부족 → 모드별 수동 조정 필요 - -### 목표 -- 그리드 기반 배치로 일관성 확보 -- 크기 프리셋으로 디자인 통일 -- 자동 반응형으로 작업량 감소 - ---- - -## 2. 대상 디바이스 - -### 지원 범위 - -| 구분 | 크기 범위 | 기준 해상도 | 비고 | -|------|----------|-------------|------| -| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 | -| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 | - -### 참고: 산업용 디바이스 해상도 - -| 디바이스 | 화면 크기 | 해상도 | -|----------|----------|--------| -| Zebra TC57 PDA | 5인치 | 720x1280 | -| Honeywell CT47 | 5.5인치 | 2160x1080 | -| Honeywell RT10A | 10.1인치 | 1920x1200 | - ---- - -## 3. 그리드 시스템 설계 - -### 3.1 브레이크포인트 (Breakpoints) - -Material Design 가이드라인 기반으로 4단계 정의: - -| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 | -|------|------|----------|---------------|-------------| -| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** | -| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** | -| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** | -| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** | - -### 3.2 기준 해상도 - -| 모드 | 기준 너비 | 기준 높이 | 비고 | -|------|----------|----------|------| -| 모바일 세로 | 375px | 667px | iPhone SE 기준 | -| 모바일 가로 | 667px | 375px | - | -| 태블릿 세로 | 768px | 1024px | iPad 기준 | -| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** | - -### 3.3 그리드 구조 - -``` -태블릿 가로 (12 columns) -┌──────────────────────────────────────────────────────────────┐ -│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│ -│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │ -└──────────────────────────────────────────────────────────────┘ - -모바일 세로 (4 columns) -┌────────────────────────┐ -│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│ -│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │ -└────────────────────────┘ -``` - -### 3.4 마진/간격 규칙 - -8px 기반 간격 시스템 (Material Design 표준): - -| 속성 | 태블릿 | 모바일 | 용도 | -|------|--------|--------|------| -| screenPadding | 24px | 16px | 화면 가장자리 여백 | -| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 | -| gapMd | 16px | 12px | 기본 간격 | -| gapLg | 24px | 16px | 섹션 간 간격 | -| rowGap | 16px | 12px | 줄 사이 간격 | - ---- - -## 4. 컴포넌트 크기 시스템 - -### 4.1 열 단위 (Span) 크기 - -픽셀 대신 **열 단위(span)** 로 크기 지정: - -| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) | -|------|--------------------|--------------------|---------------| -| XS | 1 span | 1 span | 1 span | -| S | 2 span | 2 span | 2 span | -| M | 3 span | 2 span | 2 span | -| L | 4 span | 4 span | 4 span (full) | -| XL | 6 span | 4 span | 4 span (full) | -| Full | 12 span | 8 span | 4 span | - -### 4.2 높이 프리셋 - -| 프리셋 | 픽셀값 | 용도 | -|--------|--------|------| -| `xs` | 32px | 배지, 아이콘 버튼 | -| `sm` | 48px | 일반 버튼, 입력 필드 | -| `md` | 80px | 카드, 인디케이터 | -| `lg` | 120px | 큰 카드, 리스트 아이템 | -| `xl` | 200px | 대형 영역 | -| `auto` | 내용 기반 | 가변 높이 | - -### 4.3 컴포넌트별 기본값 - -| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 | -|----------|------------|-------------|------|------| -| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 | -| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 | -| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 | -| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 | -| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 | -| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 | -| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 | -| pop-break | Full | Full | 0 | 줄바꿈 | - ---- - -## 5. 반응형 규칙 - -### 5.1 자동 조정 - -설계자가 별도 설정하지 않아도 자동 적용: - -``` -태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄 -태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄 -모바일 (4col): [A:2] [B:2] → 두 줄 - [C:2] [D:2] -``` - -### 5.2 수동 오버라이드 - -필요시 모드별 설정 가능: - -```typescript -interface ResponsiveOverride { - // 크기 변경 - span?: number; - height?: HeightPreset; - - // 표시/숨김 - hidden?: boolean; - - // 내부 요소 숨김 (컴포넌트별) - hideElements?: string[]; -} -``` - -### 5.3 표시/숨김 예시 - -``` -태블릿: [제품명] [수량] [단가] [합계] [비고] -모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김 -``` - -설정: -```typescript -{ - id: "unit-price", - type: "pop-field", - visibility: { - mobile_portrait: false, - mobile_landscape: false - } -} -``` - ---- - -## 6. 데이터 구조 (제안) - -### 6.1 레이아웃 데이터 (v5 제안) - -```typescript -interface PopLayoutDataV5 { - version: "5.0"; - - // 그리드 설정 (전역) - gridConfig: { - tablet: { columns: 12; gap: 16; padding: 24 }; - mobile: { columns: 4; gap: 8; padding: 16 }; - }; - - // 컴포넌트 목록 (순서대로) - components: PopComponentV5[]; - - // 모드별 오버라이드 (선택) - modeOverrides?: { - [mode: string]: { - gridConfig?: Partial; - componentOverrides?: Record; - }; - }; -} -``` - -### 6.2 컴포넌트 데이터 - -```typescript -interface PopComponentV5 { - id: string; - type: PopComponentType; - - // 크기 (span 단위) - size: { - span: number; // 기본 열 개수 (1~12) - height: HeightPreset; // xs, sm, md, lg, xl, auto - }; - - // 반응형 크기 (선택) - responsiveSize?: { - mobile?: { span?: number; height?: HeightPreset }; - tablet_portrait?: { span?: number; height?: HeightPreset }; - }; - - // 표시/숨김 - visibility?: { - [mode: string]: boolean; - }; - - // 컴포넌트별 설정 - config?: any; - - // 데이터 바인딩 - dataBinding?: any; -} -``` - ---- - -## 7. 현재 v4와의 관계 - -### 7.1 v4 유지 사항 -- Flexbox 기반 렌더링 -- 오버라이드 시스템 -- visibility 속성 - -### 7.2 변경 사항 - -| v4 | v5 (제안) | -|----|-----------| -| `fixedWidth: number` | `span: 1~12` | -| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 | -| 자유 픽셀 | 열 단위 프리셋 | - -### 7.3 마이그레이션 방향 - -``` -v4 fixedWidth: 200px -↓ -v5 span: 3 (태블릿 기준 약 25%) -``` - ---- - -## 8. 구현 우선순위 - -### Phase 1: 프리셋만 적용 (최소 변경) -- [ ] 높이 프리셋 드롭다운 -- [ ] 너비 프리셋 드롭다운 (XS~Full) -- [ ] 기존 Flexbox 렌더링 유지 - -### Phase 2: 그리드 시스템 도입 -- [ ] 브레이크포인트 감지 -- [ ] 그리드 칸 수 자동 변경 -- [ ] span → 픽셀 자동 계산 - -### Phase 3: 반응형 자동화 -- [ ] 모드별 자동 span 변환 -- [ ] 줄바꿈 자동 처리 -- [ ] 오버라이드 최소화 - ---- - -## 9. 참고 자료 - -### 분석 대상 - -| 도구 | 핵심 특징 | 적용 가능 요소 | -|------|----------|---------------| -| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 | -| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 | -| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 | - -### 핵심 원칙 - -1. **Flexbox는 도구**: 그리드 시스템 안에서 사용 -2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능 -3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요 - ---- - -## 10. FAQ - -### Q1: 기존 v4 화면은 어떻게 되나요? -A: 하위 호환 유지. v4 화면은 v4로 계속 동작. - -### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요? -A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심. - -### Q3: 그리드 칸 수를 바꿀 수 있나요? -A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능. - -### Q4: Flexbox와 Grid 중 뭘 쓰나요? -A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬. - ---- - -*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.* -*최종 업데이트: 2026-02-05* diff --git a/popdocs/archive/GRID_SYSTEM_PLAN.md b/popdocs/archive/GRID_SYSTEM_PLAN.md deleted file mode 100644 index 5ee366b5..00000000 --- a/popdocs/archive/GRID_SYSTEM_PLAN.md +++ /dev/null @@ -1,480 +0,0 @@ -# POP 그리드 시스템 도입 계획 - -> 작성일: 2026-02-05 -> 상태: 계획 승인, 구현 대기 - ---- - -## 개요 - -### 목표 -현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여 -4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현 - -### 핵심 변경점 - -| 항목 | v4 (현재) | v5 (그리드) | -|------|----------|-------------| -| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** | -| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** | -| 위치 지정 | 순서대로 자동 | **열/행 좌표** | -| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** | - ---- - -## Phase 구조 - -``` -[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4] -그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화 - 1주 1주 1~2주 1주 -``` - ---- - -## Phase 5.1: 그리드 타입 정의 - -### 목표 -v5 레이아웃 데이터 구조 설계 - -### 작업 항목 - -- [ ] `PopLayoutDataV5` 인터페이스 정의 -- [ ] `PopGridConfig` 인터페이스 (그리드 설정) -- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan) -- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기) -- [ ] 브레이크포인트 상수 정의 -- [ ] `createEmptyPopLayoutV5()` 생성 함수 -- [ ] `isV5Layout()` 타입 가드 - -### 데이터 구조 설계 - -```typescript -// v5 레이아웃 -interface PopLayoutDataV5 { - version: "pop-5.0"; - - // 그리드 설정 - gridConfig: PopGridConfig; - - // 컴포넌트 목록 - components: Record; - - // 모드별 오버라이드 - overrides?: { - mobile_portrait?: PopModeOverrideV5; - mobile_landscape?: PopModeOverrideV5; - tablet_portrait?: PopModeOverrideV5; - }; - - // 기존 호환 - dataFlow: PopDataFlow; - settings: PopGlobalSettingsV5; -} - -// 그리드 설정 -interface PopGridConfig { - // 모드별 칸 수 - columns: { - tablet_landscape: 12; // 기본 (10~14인치) - tablet_portrait: 8; // 8~10인치 세로 - mobile_landscape: 6; // 6~8인치 가로 - mobile_portrait: 4; // 4~6인치 세로 - }; - - // 행 높이 (px) - 1행의 기본 높이 - rowHeight: number; // 기본 48px - - // 간격 - gap: number; // 기본 8px - padding: number; // 기본 16px -} - -// 컴포넌트 정의 -interface PopComponentDefinitionV5 { - id: string; - type: PopComponentType; - label?: string; - - // 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준 - position: { - col: number; // 시작 열 (1부터) - row: number; // 시작 행 (1부터) - colSpan: number; // 차지할 열 수 (1~12) - rowSpan: number; // 차지할 행 수 (1~) - }; - - // 모드별 표시/숨김 - visibility?: { - tablet_landscape?: boolean; - tablet_portrait?: boolean; - mobile_landscape?: boolean; - mobile_portrait?: boolean; - }; - - // 기존 속성 - dataBinding?: PopDataBinding; - config?: PopComponentConfig; -} -``` - -### 브레이크포인트 정의 - -```typescript -// 브레이크포인트 상수 -const GRID_BREAKPOINTS = { - // 4~6인치 모바일 세로 - mobile_portrait: { - maxWidth: 599, - columns: 4, - rowHeight: 40, - gap: 8, - padding: 12, - }, - - // 6~8인치 모바일 가로 / 작은 태블릿 - mobile_landscape: { - minWidth: 600, - maxWidth: 839, - columns: 6, - rowHeight: 44, - gap: 8, - padding: 16, - }, - - // 8~10인치 태블릿 세로 - tablet_portrait: { - minWidth: 840, - maxWidth: 1023, - columns: 8, - rowHeight: 48, - gap: 12, - padding: 16, - }, - - // 10~14인치 태블릿 가로 (기본) - tablet_landscape: { - minWidth: 1024, - columns: 12, - rowHeight: 48, - gap: 16, - padding: 24, - }, -} as const; -``` - -### 산출물 -- `frontend/components/pop/designer/types/pop-layout-v5.ts` - ---- - -## Phase 5.2: 그리드 렌더러 - -### 목표 -CSS Grid 기반 렌더러 구현 - -### 작업 항목 - -- [ ] `PopGridRenderer.tsx` 생성 -- [ ] CSS Grid 스타일 계산 로직 -- [ ] 브레이크포인트 감지 및 칸 수 자동 변경 -- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row) -- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환) -- [ ] visibility 처리 -- [ ] 기존 PopFlexRenderer와 공존 - -### 렌더링 로직 - -```typescript -// CSS Grid 스타일 생성 -function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties { - const columns = config.columns[mode]; - const { rowHeight, gap, padding } = config; - - return { - display: 'grid', - gridTemplateColumns: `repeat(${columns}, 1fr)`, - gridAutoRows: `${rowHeight}px`, - gap: `${gap}px`, - padding: `${padding}px`, - }; -} - -// 컴포넌트 위치 스타일 -function calculatePositionStyle( - position: PopComponentPositionV5['position'], - sourceColumns: number, // 원본 모드 칸 수 (12) - targetColumns: number // 현재 모드 칸 수 (4) -): React.CSSProperties { - // 12칸 → 4칸 변환 예시 - // col: 7, colSpan: 3 → col: 3, colSpan: 1 - const ratio = targetColumns / sourceColumns; - const newCol = Math.max(1, Math.ceil(position.col * ratio)); - const newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - return { - gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`, - gridRow: `${position.row} / span ${position.rowSpan}`, - }; -} -``` - -### 산출물 -- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx` - ---- - -## Phase 5.3: 디자이너 UI - -### 목표 -그리드 기반 편집 UI 구현 - -### 작업 항목 - -- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스) -- [ ] 그리드 배경 표시 (바둑판 모양) -- [ ] 컴포넌트 드래그 배치 (칸에 스냅) -- [ ] 컴포넌트 리사이즈 (칸 단위) -- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan) -- [ ] 모드 전환 시 그리드 칸 수 변경 표시 -- [ ] v4/v5 자동 판별 및 전환 - -### UI 구조 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │ -├─────────────────────────────────────────────────────────────────┤ -│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │ -├────────────┬────────────────────────────────────┬───────────────┤ -│ │ 1 2 3 4 5 6 ... 12 │ │ -│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │ -│ │1│ A │ B │ │ 열: [1-12] │ -│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │ -│ 버튼 │2│ C │ │ 너비: [1-12]│ -│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│ -│ 인디케이터 │3│ D │ E │ │ │ -│ ... │ └───────────┴───────────┘ │ 표시 설정 │ -│ │ │ [x] 태블릿↔ │ -│ │ (그리드 배경 표시) │ [x] 모바일↕ │ -└────────────┴────────────────────────────────────┴───────────────┘ -``` - -### 드래그 앤 드롭 로직 - -```typescript -// 마우스 위치 → 그리드 좌표 변환 -function mouseToGridPosition( - mouseX: number, - mouseY: number, - gridConfig: PopGridConfig, - canvasRect: DOMRect -): { col: number; row: number } { - const { columns, rowHeight, gap, padding } = gridConfig; - - // 캔버스 내 상대 위치 - const relX = mouseX - canvasRect.left - padding; - const relY = mouseY - canvasRect.top - padding; - - // 칸 너비 계산 - const totalGap = gap * (columns - 1); - const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; - - // 그리드 좌표 계산 - const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); - const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); - - return { col, row }; -} -``` - -### 산출물 -- `frontend/components/pop/designer/PopCanvasV5.tsx` -- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx` - ---- - -## Phase 5.4: 반응형 자동화 - -### 목표 -모드 전환 시 자동 레이아웃 조정 - -### 작업 항목 - -- [ ] 12칸 → 4칸 자동 변환 알고리즘 -- [ ] 겹침 감지 및 자동 재배치 -- [ ] 모드별 오버라이드 저장 -- [ ] "자동 배치" vs "수동 고정" 선택 -- [ ] 변환 미리보기 - -### 자동 변환 알고리즘 - -```typescript -// 12칸 → 4칸 변환 전략 -function convertLayoutToMode( - components: PopComponentDefinitionV5[], - sourceMode: 'tablet_landscape', // 12칸 - targetMode: 'mobile_portrait' // 4칸 -): PopComponentDefinitionV5[] { - const sourceColumns = 12; - const targetColumns = 4; - const ratio = targetColumns / sourceColumns; // 0.333 - - // 1. 각 컴포넌트 위치 변환 - const converted = components.map(comp => { - const newCol = Math.max(1, Math.ceil(comp.position.col * ratio)); - const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio)); - - return { - ...comp, - position: { - ...comp.position, - col: newCol, - colSpan: Math.min(newColSpan, targetColumns), - }, - }; - }); - - // 2. 겹침 감지 및 해결 - return resolveOverlaps(converted, targetColumns); -} - -// 겹침 해결 (아래로 밀기) -function resolveOverlaps( - components: PopComponentDefinitionV5[], - columns: number -): PopComponentDefinitionV5[] { - // 행 단위로 그리드 점유 상태 추적 - const grid: boolean[][] = []; - - // row 순서대로 처리 - const sorted = [...components].sort((a, b) => - a.position.row - b.position.row || a.position.col - b.position.col - ); - - return sorted.map(comp => { - let { row, col, colSpan, rowSpan } = comp.position; - - // 배치 가능한 위치 찾기 - while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) { - row++; // 아래로 이동 - } - - // 그리드에 표시 - markOccupied(grid, row, col, colSpan, rowSpan); - - return { - ...comp, - position: { row, col, colSpan, rowSpan }, - }; - }); -} -``` - -### 산출물 -- `frontend/components/pop/designer/utils/gridLayoutUtils.ts` - ---- - -## 마이그레이션 전략 - -### v4 → v5 변환 - -```typescript -function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 { - const componentsList = Object.values(layoutV4.components); - - // Flexbox 순서 → Grid 위치 변환 - let currentRow = 1; - let currentCol = 1; - const columns = 12; - - const componentsV5: Record = {}; - - componentsList.forEach((comp, index) => { - // 기본 크기 추정 (픽셀 → 칸) - const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85)); - const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48)); - - // 줄바꿈 체크 - if (currentCol + colSpan - 1 > columns) { - currentRow++; - currentCol = 1; - } - - componentsV5[comp.id] = { - ...comp, - position: { - col: currentCol, - row: currentRow, - colSpan, - rowSpan, - }, - }; - - currentCol += colSpan; - }); - - return { - version: "pop-5.0", - gridConfig: { /* 기본값 */ }, - components: componentsV5, - dataFlow: layoutV4.dataFlow, - settings: { /* 변환 */ }, - }; -} -``` - -### 하위 호환 - -| 버전 | 처리 방식 | -|------|----------| -| v1~v2 | v3로 변환 후 v5로 | -| v3 | v5로 직접 변환 | -| v4 | v5로 직접 변환 | -| v5 | 그대로 사용 | - ---- - -## 일정 (예상) - -| Phase | 작업 | 예상 기간 | -|-------|------|----------| -| 5.1 | 타입 정의 | 2~3일 | -| 5.2 | 그리드 렌더러 | 3~5일 | -| 5.3 | 디자이너 UI | 5~7일 | -| 5.4 | 반응형 자동화 | 3~5일 | -| - | 테스트 및 버그 수정 | 2~3일 | -| **총** | | **약 2~3주** | - ---- - -## 리스크 및 대응 - -| 리스크 | 영향 | 대응 | -|--------|------|------| -| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 | -| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 | -| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 | -| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 | - ---- - -## 성공 기준 - -1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시 -2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지 -3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치 -4. **하위 호환**: 기존 v4 화면이 정상 동작 - ---- - -## 관련 문서 - -- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세 -- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획 -- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙 - ---- - -*최종 업데이트: 2026-02-05* diff --git a/popdocs/archive/PHASE3_SUMMARY.md b/popdocs/archive/PHASE3_SUMMARY.md deleted file mode 100644 index 4c08ae56..00000000 --- a/popdocs/archive/PHASE3_SUMMARY.md +++ /dev/null @@ -1,518 +0,0 @@ -# Phase 3 완료 요약 - -**날짜**: 2026-02-04 -**상태**: 완료 ✅ -**버전**: v4.0 Phase 3 - ---- - -## 🎯 달성 목표 - -Phase 2의 배치 고정 기능 이후, 다음 3가지 핵심 기능 추가: - -1. ✅ **모드별 컴포넌트 표시/숨김** (visibility) -2. ✅ **강제 줄바꿈 컴포넌트** (pop-break) -3. ✅ **컴포넌트 오버라이드 병합** (모드별 설정 변경) - ---- - -## 📦 구현 내용 - -### 1. 타입 정의 - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -```typescript -// pop-break 추가 -export type PopComponentType = - | "pop-field" - | "pop-button" - | "pop-list" - | "pop-indicator" - | "pop-scanner" - | "pop-numpad" - | "pop-spacer" - | "pop-break"; // 🆕 - -// visibility 속성 추가 -export interface PopComponentDefinitionV4 { - id: string; - type: PopComponentType; - size: PopSizeConstraintV4; - - visibility?: { - tablet_landscape?: boolean; - tablet_portrait?: boolean; - mobile_landscape?: boolean; - mobile_portrait?: boolean; - }; - - // ... -} - -// 기본 크기 -defaultSizes["pop-break"] = { - width: "fill", // 100% 너비 - height: "fixed", - fixedHeight: 0, // 높이 0 -}; -``` - ---- - -### 2. 렌더러 로직 - -**파일**: `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx` - -#### visibility 체크 -```typescript -const isComponentVisible = (component: PopComponentDefinitionV4): boolean => { - if (!component.visibility) return true; // 기본값: 표시 - const modeVisibility = component.visibility[currentMode]; - return modeVisibility !== false; // undefined도 true로 취급 -}; -``` - -#### 컴포넌트 오버라이드 병합 -```typescript -const getMergedComponent = (baseComponent: PopComponentDefinitionV4) => { - if (currentMode === "tablet_landscape") return baseComponent; - - const override = overrides?.[currentMode]?.components?.[baseComponent.id]; - if (!override) return baseComponent; - - // 깊은 병합 (config, size) - return { - ...baseComponent, - ...override, - size: { ...baseComponent.size, ...override.size }, - config: { ...baseComponent.config, ...override.config }, - }; -}; -``` - -#### pop-break 렌더링 -```typescript -if (mergedComponent.type === "pop-break") { - return ( -
- {isDesignMode && 줄바꿈} -
- ); -} -``` - ---- - -### 3. 삭제 함수 개선 - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -```typescript -export const removeComponentFromV4Layout = ( - layout: PopLayoutDataV4, - componentId: string -): PopLayoutDataV4 => { - // 1. components에서 삭제 - const { [componentId]: _, ...remainingComponents } = layout.components; - - // 2. root.children에서 제거 - const newRoot = removeChildFromContainer(layout.root, componentId); - - // 3. 🆕 모든 오버라이드에서 제거 - const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId); - - return { - ...layout, - root: newRoot, - components: remainingComponents, - overrides: newOverrides, - }; -}; -``` - -#### 오버라이드 정리 로직 -```typescript -function cleanupOverridesAfterDelete( - overrides: PopLayoutDataV4["overrides"], - componentId: string -) { - // 각 모드별로: - // 1. containers.root.children에서 componentId 제거 - // 2. components[componentId] 제거 - // 3. 빈 오버라이드 자동 삭제 -} -``` - ---- - -### 4. 속성 패널 UI - -**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx` - -#### "표시" 탭 추가 -```typescript - - 크기 - 설정 - - - 표시 - - 데이터 - -``` - -#### VisibilityForm 컴포넌트 -```typescript -function VisibilityForm({ component, onUpdate }) { - const modes = [ - { key: "tablet_landscape", label: "태블릿 가로" }, - { key: "tablet_portrait", label: "태블릿 세로" }, - { key: "mobile_landscape", label: "모바일 가로" }, - { key: "mobile_portrait", label: "모바일 세로" }, - ]; - - return ( -
- {modes.map(({ key, label }) => ( - { - onUpdate?.({ - visibility: { - ...component.visibility, - [key]: e.target.checked, - }, - }); - }} - /> - ))} -
- ); -} -``` - ---- - -### 5. 팔레트 업데이트 - -**파일**: `frontend/components/pop/designer/panels/ComponentPaletteV4.tsx` - -```typescript -const COMPONENT_PALETTE = [ - // ... 기존 컴포넌트들 - { - type: "pop-break", - label: "줄바꿈", - icon: WrapText, - description: "강제 줄바꿈 (flex-basis: 100%)", - }, -]; -``` - ---- - -## 🎨 UI 변경사항 - -### 컴포넌트 팔레트 -``` -컴포넌트 -├─ 필드 -├─ 버튼 -├─ 리스트 -├─ 인디케이터 -├─ 스캐너 -├─ 숫자패드 -├─ 스페이서 -└─ 줄바꿈 🆕 -``` - -### 속성 패널 -``` -┌─────────────────────┐ -│ 탭: [크기][설정] │ -│ [표시📍][데이터] │ -├─────────────────────┤ -│ 모드별 표시 설정 │ -│ ☑ 태블릿 가로 │ -│ ☑ 태블릿 세로 │ -│ ☐ 모바일 가로 (숨김)│ -│ ☑ 모바일 세로 │ -├─────────────────────┤ -│ 반응형 숨김 │ -│ [500] px 이하 숨김 │ -└─────────────────────┘ -``` - ---- - -## 📖 사용 예시 - -### 예시 1: 모바일 전용 버튼 -```typescript -{ - id: "call-button", - type: "pop-button", - label: "전화 걸기", - visibility: { - tablet_landscape: false, // 태블릿: 숨김 - tablet_portrait: false, - mobile_landscape: true, // 모바일: 표시 - mobile_portrait: true, - }, -} -``` - -**결과**: -- 태블릿 화면: "전화 걸기" 버튼 안 보임 -- 모바일 화면: "전화 걸기" 버튼 보임 - ---- - -### 예시 2: 모드별 줄바꿈 -```typescript -레이아웃: [A] [B] [줄바꿈] [C] [D] - -줄바꿈 설정: -{ - id: "break-1", - type: "pop-break", - visibility: { - tablet_landscape: false, // 태블릿: 줄바꿈 숨김 - mobile_portrait: true, // 모바일: 줄바꿈 표시 - } -} -``` - -**결과**: -``` -태블릿 가로 (1024px): -┌───────────────────────────┐ -│ [A] [B] [C] [D] │ ← 한 줄 -└───────────────────────────┘ - -모바일 세로 (375px): -┌─────────────────┐ -│ [A] [B] │ ← 첫 줄 -│ [C] [D] │ ← 둘째 줄 (줄바꿈 적용) -└─────────────────┘ -``` - ---- - -### 예시 3: 리스트 컬럼 수 변경 (확장 가능) -```typescript -// 기본 (태블릿 가로) -{ - id: "product-list", - type: "pop-list", - config: { - columns: 7, // 7개 컬럼 - } -} - -// 오버라이드 (모바일 세로) -overrides: { - mobile_portrait: { - components: { - "product-list": { - config: { - columns: 3, // 3개 컬럼 - } - } - } - } -} -``` - -**결과**: -- 태블릿: 7개 컬럼 표시 -- 모바일: 3개 컬럼 표시 (자동 병합) - ---- - -## 🧪 테스트 시나리오 - -### ✅ 테스트 1: 줄바꿈 기본 동작 -1. 팔레트에서 "줄바꿈" 드래그 -2. 컴포넌트 사이에 드롭 -3. 디자인 모드에서 점선 "줄바꿈" 표시 확인 -4. 미리보기에서 줄바꿈이 안 보이는지 확인 - -### ✅ 테스트 2: 모드별 줄바꿈 -1. 줄바꿈 컴포넌트 추가 -2. "표시" 탭 → 태블릿 모드 체크 해제 -3. 태블릿 가로: 한 줄 -4. 모바일 세로: 두 줄 - -### ✅ 테스트 3: 삭제 시 오버라이드 정리 -1. 모바일 세로에서 배치 고정 -2. 컴포넌트 삭제 -3. 저장 후 로드 -4. DB 확인: overrides에서도 제거되었는지 - -### ✅ 테스트 4: 컴포넌트 숨김 -1. 컴포넌트 선택 -2. "표시" 탭 → 태블릿 모드 체크 해제 -3. 태블릿: 컴포넌트 안 보임 -4. 모바일: 컴포넌트 보임 - -### ✅ 테스트 5: 속성 패널 UI -1. 컴포넌트 선택 -2. "표시" 탭 클릭 -3. 4개 체크박스 확인 -4. 체크 해제 시 "(숨김)" 표시 -5. 저장 후 로드 → 상태 유지 - ---- - -## 📝 수정된 파일 - -### 코드 파일 (5개) -``` -✅ frontend/components/pop/designer/types/pop-layout.ts - - PopComponentType 확장 (pop-break) - - PopComponentDefinitionV4.visibility 추가 - - cleanupOverridesAfterDelete() 추가 - -✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx - - isComponentVisible() 추가 - - getMergedComponent() 추가 - - pop-break 렌더링 추가 - - ContainerRenderer props 확장 - -✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx - - "표시" 탭 추가 - - VisibilityForm 컴포넌트 추가 - - COMPONENT_TYPE_LABELS 업데이트 - -✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx - - "줄바꿈" 컴포넌트 추가 - -✅ frontend/components/pop/designer/PopDesigner.tsx - - (기존 Phase 2 변경사항 유지) -``` - -### 문서 파일 (6개) -``` -✅ popdocs/CHANGELOG.md - - Phase 3 완료 기록 - -✅ popdocs/PLAN.md - - Phase 3 체크 완료 - - Phase 4 계획 추가 - -✅ popdocs/V4_UNIFIED_DESIGN_SPEC.md - - Phase 3 섹션 추가 - -✅ popdocs/components-spec.md - - pop-break 상세 스펙 추가 - - Phase 3 업데이트 노트 - -✅ popdocs/README.md - - 현재 상태 업데이트 - - Phase 3 요약 추가 - -✅ popdocs/decisions/002-phase3-visibility-break.md (신규) - - 상세 설계 문서 - -✅ popdocs/PHASE3_SUMMARY.md (신규) - - 이 문서 -``` - ---- - -## 🎓 핵심 개념 - -### Flexbox 줄바꿈 원리 -```css -/* 컨테이너 */ -.container { - display: flex; - flex-direction: row; - flex-wrap: wrap; /* 필수 */ -} - -/* pop-break */ -.pop-break { - flex-basis: 100%; /* 전체 너비 차지 → 다음 요소는 새 줄로 */ - height: 0; /* 실제로는 안 보임 */ -} -``` - -### visibility vs hideBelow -| 속성 | 제어 방식 | 용도 | -|------|----------|------| -| `visibility` | 모드별 명시적 | 특정 모드에서만 표시 (예: 모바일 전용) | -| `hideBelow` | 픽셀 기반 자동 | 화면 너비에 따라 자동 숨김 (예: 500px 이하) | - -**예시**: -```typescript -{ - visibility: { - tablet_landscape: false, // 태블릿 가로: 무조건 숨김 - }, - hideBelow: 500, // 500px 이하: 자동 숨김 (다른 모드에서도) -} -``` - ---- - -## 🚀 다음 단계 - -### Phase 4: 실제 컴포넌트 구현 -``` -우선순위: -1. pop-field (입력/표시 필드) -2. pop-button (액션 버튼) -3. pop-list (데이터 리스트) -4. pop-indicator (KPI 표시) -5. pop-scanner (바코드/QR) -6. pop-numpad (숫자 입력) -``` - -### 추가 개선 사항 -``` -1. 컴포넌트 오버라이드 UI - - 리스트 컬럼 수 조정 UI - - 버튼 스타일 변경 UI - - 필드 표시 형식 변경 UI - -2. "모든 모드에 적용" 기능 - - 한 번에 모든 모드 체크/해제 - -3. 오버라이드 비교 뷰 - - 기본값 vs 오버라이드 차이 시각화 -``` - ---- - -## ✨ 주요 성과 - -1. ✅ **모드별 컴포넌트 제어**: visibility 속성으로 유연한 표시/숨김 -2. ✅ **Flexbox 줄바꿈 해결**: pop-break 컴포넌트로 업계 표준 달성 -3. ✅ **확장 가능한 구조**: 컴포넌트 오버라이드 병합으로 추후 기능 추가 용이 -4. ✅ **데이터 일관성**: 삭제 시 오버라이드 자동 정리로 데이터 무결성 유지 -5. ✅ **직관적인 UI**: 체크박스 기반 visibility 제어 - ---- - -## 📚 참고 문서 - -- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계 -- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계 -- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력 -- [PLAN.md](./PLAN.md) - 로드맵 - ---- - -*Phase 3 완료 - 2026-02-04* -*다음: Phase 4 (실제 컴포넌트 구현)* diff --git a/popdocs/archive/POPREADME.md b/popdocs/archive/POPREADME.md deleted file mode 100644 index d1fe8519..00000000 --- a/popdocs/archive/POPREADME.md +++ /dev/null @@ -1,658 +0,0 @@ -# POP 화면 시스템 구현 계획서 - -## 개요 - -Vexplor 서비스 내에서 POP(Point of Production) 화면을 구성할 수 있는 시스템을 구현합니다. -기존 Vexplor와 충돌 없이 별도 공간에서 개발하되, 장기적으로 통합 가능하도록 동일한 서비스 로직을 사용합니다. - ---- - -## 핵심 원칙 - -| 원칙 | 설명 | -|------|------| -| **충돌 방지** | POP 전용 공간에서 개발 | -| **통합 준비** | 기본 서비스 로직은 Vexplor와 동일 | -| **데이터 공유** | 같은 DB, 같은 데이터 소스 사용 | - ---- - -## 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ [데이터베이스] │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ screen_ │ │ screen_layouts_ │ │ screen_layouts_ │ │ -│ │ definitions │ │ v2 (데스크톱) │ │ pop (POP) │ │ -│ │ (공통) │ └─────────────────┘ └─────────────────┘ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ [백엔드 API] │ -│ /screen-management/screens/:id/layout-v2 (데스크톱) │ -│ /screen-management/screens/:id/layout-pop (POP) │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────┴───────────────┐ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────┐ -│ [프론트엔드 - 데스크톱] │ │ [프론트엔드 - POP] │ -│ │ │ │ -│ app/(main)/ │ │ app/(pop)/ │ -│ lib/registry/ │ │ lib/registry/ │ -│ components/ │ │ pop-components/ │ -│ components/screen/ │ │ components/pop/ │ -└─────────────────────────┘ └─────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────┐ -│ PC 브라우저 │ │ 모바일/태블릿 브라우저 │ -│ (마우스 + 키보드) │ │ (터치 + 스캐너) │ -└─────────────────────────┘ └─────────────────────────┘ -``` - ---- - -## 1. 데이터베이스 변경사항 - -### 1-1. 테이블 추가/유지 현황 - -| 구분 | 테이블명 | 변경 내용 | 비고 | -|------|----------|----------|------| -| **추가** | `screen_layouts_pop` | POP 레이아웃 저장용 | 신규 테이블 | -| **유지** | `screen_definitions` | 변경 없음 | 공통 사용 | -| **유지** | `screen_layouts_v2` | 변경 없음 | 데스크톱 전용 | - -### 1-2. 신규 테이블 DDL - -```sql --- 마이그레이션 파일: db/migrations/XXX_create_screen_layouts_pop.sql - -CREATE TABLE screen_layouts_pop ( - layout_id SERIAL PRIMARY KEY, - screen_id INTEGER NOT NULL REFERENCES screen_definitions(screen_id), - company_code VARCHAR(20) NOT NULL, - layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- 반응형 레이아웃 JSON - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - created_by VARCHAR(50), - updated_by VARCHAR(50), - UNIQUE(screen_id, company_code) -); - -CREATE INDEX idx_pop_screen_id ON screen_layouts_pop(screen_id); -CREATE INDEX idx_pop_company_code ON screen_layouts_pop(company_code); - -COMMENT ON TABLE screen_layouts_pop IS 'POP 화면 레이아웃 저장 테이블 (모바일/태블릿 반응형)'; -COMMENT ON COLUMN screen_layouts_pop.layout_data IS 'V2 형식의 레이아웃 JSON (반응형 구조)'; -``` - -### 1-3. 레이아웃 JSON 구조 (V2 형식 동일) - -```json -{ - "version": "2.0", - "components": [ - { - "id": "comp_xxx", - "url": "@/lib/registry/pop-components/pop-card-list", - "position": { "x": 0, "y": 0 }, - "size": { "width": 100, "height": 50 }, - "displayOrder": 0, - "overrides": { - "tableName": "user_info", - "columns": ["id", "name", "status"], - "cardStyle": "compact" - } - } - ], - "updatedAt": "2026-01-29T12:00:00Z" -} -``` - ---- - -## 2. 백엔드 변경사항 - -### 2-1. 파일 수정 목록 - -| 구분 | 파일 경로 | 변경 내용 | -|------|----------|----------| -| **수정** | `backend-node/src/services/screenManagementService.ts` | POP 레이아웃 CRUD 함수 추가 | -| **수정** | `backend-node/src/routes/screenManagementRoutes.ts` | POP API 엔드포인트 추가 | - -### 2-2. 추가 API 엔드포인트 - -``` -GET /screen-management/screens/:screenId/layout-pop # POP 레이아웃 조회 -POST /screen-management/screens/:screenId/layout-pop # POP 레이아웃 저장 -DELETE /screen-management/screens/:screenId/layout-pop # POP 레이아웃 삭제 -``` - -### 2-3. screenManagementService.ts 추가 함수 - -```typescript -// 기존 함수 (유지) -getScreenLayoutV2(screenId, companyCode) -saveLayoutV2(screenId, companyCode, layoutData) - -// 추가 함수 (신규) - 로직은 V2와 동일, 테이블명만 다름 -getScreenLayoutPop(screenId, companyCode) -saveLayoutPop(screenId, companyCode, layoutData) -deleteLayoutPop(screenId, companyCode) -``` - ---- - -## 3. 프론트엔드 변경사항 - -### 3-1. 폴더 구조 - -``` -frontend/ -├── app/ -│ └── (pop)/ # [기존] POP 라우팅 그룹 -│ ├── layout.tsx # [수정] POP 전용 레이아웃 -│ ├── pop/ -│ │ └── page.tsx # [기존] POP 메인 -│ └── screens/ # [추가] POP 화면 뷰어 -│ └── [screenId]/ -│ └── page.tsx # [추가] POP 동적 화면 -│ -├── lib/ -│ ├── api/ -│ │ └── screen.ts # [수정] POP API 함수 추가 -│ │ -│ ├── registry/ -│ │ ├── pop-components/ # [추가] POP 전용 컴포넌트 -│ │ │ ├── pop-card-list/ -│ │ │ │ ├── PopCardListComponent.tsx -│ │ │ │ ├── PopCardListConfigPanel.tsx -│ │ │ │ └── index.ts -│ │ │ ├── pop-touch-button/ -│ │ │ ├── pop-scanner-input/ -│ │ │ └── index.ts # POP 컴포넌트 내보내기 -│ │ │ -│ │ ├── PopComponentRegistry.ts # [추가] POP 컴포넌트 레지스트리 -│ │ └── ComponentRegistry.ts # [유지] 기존 유지 -│ │ -│ ├── schemas/ -│ │ └── popComponentConfig.ts # [추가] POP용 Zod 스키마 -│ │ -│ └── utils/ -│ └── layoutPopConverter.ts # [추가] POP 레이아웃 변환기 -│ -└── components/ - └── pop/ # [기존] POP UI 컴포넌트 - ├── PopScreenDesigner.tsx # [추가] POP 화면 설계 도구 - ├── PopPreview.tsx # [추가] POP 미리보기 - └── PopDynamicRenderer.tsx # [추가] POP 동적 렌더러 -``` - -### 3-2. 파일별 상세 내용 - -#### A. 신규 파일 (추가) - -| 파일 | 역할 | 기반 | -|------|------|------| -| `app/(pop)/screens/[screenId]/page.tsx` | POP 화면 뷰어 | `app/(main)/screens/[screenId]/page.tsx` 참고 | -| `lib/registry/PopComponentRegistry.ts` | POP 컴포넌트 등록 | `ComponentRegistry.ts` 구조 동일 | -| `lib/registry/pop-components/*` | POP 전용 컴포넌트 | 신규 개발 | -| `lib/schemas/popComponentConfig.ts` | POP Zod 스키마 | `componentConfig.ts` 구조 동일 | -| `lib/utils/layoutPopConverter.ts` | POP 레이아웃 변환 | `layoutV2Converter.ts` 구조 동일 | -| `components/pop/PopScreenDesigner.tsx` | POP 화면 설계 | 신규 개발 | -| `components/pop/PopDynamicRenderer.tsx` | POP 동적 렌더러 | `DynamicComponentRenderer.tsx` 참고 | - -#### B. 수정 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `lib/api/screen.ts` | `getLayoutPop()`, `saveLayoutPop()` 함수 추가 | -| `app/(pop)/layout.tsx` | POP 전용 레이아웃 스타일 적용 | - -#### C. 유지 파일 (변경 없음) - -| 파일 | 이유 | -|------|------| -| `lib/registry/ComponentRegistry.ts` | 데스크톱 전용, 분리 유지 | -| `lib/schemas/componentConfig.ts` | 데스크톱 전용, 분리 유지 | -| `lib/utils/layoutV2Converter.ts` | 데스크톱 전용, 분리 유지 | -| `app/(main)/*` | 데스크톱 전용, 변경 없음 | - ---- - -## 4. 서비스 로직 흐름 - -### 4-1. 데스크톱 (기존 - 변경 없음) - -``` -[사용자] → /screens/123 접속 - ↓ -[app/(main)/screens/[screenId]/page.tsx] - ↓ -[getLayoutV2(screenId)] → API 호출 - ↓ -[screen_layouts_v2 테이블] → 레이아웃 JSON 반환 - ↓ -[DynamicComponentRenderer] → 컴포넌트 렌더링 - ↓ -[ComponentRegistry] → 컴포넌트 찾기 - ↓ -[lib/registry/components/table-list] → 컴포넌트 실행 - ↓ -[화면 표시] -``` - -### 4-2. POP (신규 - 동일 로직) - -``` -[사용자] → /pop/screens/123 접속 - ↓ -[app/(pop)/screens/[screenId]/page.tsx] - ↓ -[getLayoutPop(screenId)] → API 호출 - ↓ -[screen_layouts_pop 테이블] → 레이아웃 JSON 반환 - ↓ -[PopDynamicRenderer] → 컴포넌트 렌더링 - ↓ -[PopComponentRegistry] → 컴포넌트 찾기 - ↓ -[lib/registry/pop-components/pop-card-list] → 컴포넌트 실행 - ↓ -[화면 표시] -``` - ---- - -## 5. 로직 변경 여부 - -| 구분 | 로직 변경 | 설명 | -|------|----------|------| -| 데이터베이스 CRUD | **없음** | 동일한 SELECT/INSERT/UPDATE 패턴 | -| API 호출 방식 | **없음** | 동일한 REST API 패턴 | -| 컴포넌트 렌더링 | **없음** | 동일한 URL 기반 + overrides 방식 | -| Zod 스키마 검증 | **없음** | 동일한 검증 로직 | -| 레이아웃 JSON 구조 | **없음** | 동일한 V2 JSON 구조 사용 | - -**결론: 로직 변경 없음, 파일/테이블 분리만 진행** - ---- - -## 6. 데스크톱 vs POP 비교 - -| 구분 | Vexplor (데스크톱) | POP (모바일/태블릿) | -|------|-------------------|---------------------| -| **타겟 기기** | PC (마우스+키보드) | 모바일/태블릿 (터치) | -| **화면 크기** | 1920x1080 고정 | 반응형 (다양한 크기) | -| **UI 스타일** | 테이블 중심, 작은 버튼 | 카드 중심, 큰 터치 버튼 | -| **입력 방식** | 키보드 타이핑 | 터치, 스캐너, 음성 | -| **사용 환경** | 사무실 | 현장, 창고, 공장 | -| **레이아웃 테이블** | `screen_layouts_v2` | `screen_layouts_pop` | -| **컴포넌트 경로** | `lib/registry/components/` | `lib/registry/pop-components/` | -| **레지스트리** | `ComponentRegistry.ts` | `PopComponentRegistry.ts` | - ---- - -## 7. 장기 통합 시나리오 - -### Phase 1: 분리 개발 (현재 목표) - -``` -[데스크톱] [POP] -ComponentRegistry PopComponentRegistry -components/ pop-components/ -screen_layouts_v2 screen_layouts_pop -``` - -### Phase 2: 부분 통합 (향후) - -``` -[통합 가능한 부분] -- 공통 유틸리티 함수 -- 공통 Zod 스키마 -- 공통 타입 정의 - -[분리 유지] -- 플랫폼별 컴포넌트 -- 플랫폼별 레이아웃 -``` - -### Phase 3: 완전 통합 (최종) - -``` -[단일 컴포넌트 레지스트리] -ComponentRegistry -├── components/ (공통) -├── desktop-components/ (데스크톱 전용) -└── pop-components/ (POP 전용) - -[단일 레이아웃 테이블] (선택사항) -screen_layouts -├── platform = 'desktop' -└── platform = 'pop' -``` - ---- - -## 8. V2 공통 요소 (통합 핵심) - -POP과 데스크톱이 장기적으로 통합될 수 있는 **핵심 기반**입니다. - -### 8-1. 공통 유틸리티 함수 - -**파일 위치:** `frontend/lib/schemas/componentConfig.ts`, `frontend/lib/utils/layoutV2Converter.ts` - -#### 핵심 병합/추출 함수 (가장 중요!) - -| 함수명 | 역할 | 사용 시점 | -|--------|------|----------| -| `deepMerge()` | 객체 깊은 병합 | 기본값 + overrides 합칠 때 | -| `mergeComponentConfig()` | 기본값 + 커스텀 병합 | **렌더링 시** (화면 표시) | -| `extractCustomConfig()` | 기본값과 다른 부분만 추출 | **저장 시** (DB 저장) | -| `isDeepEqual()` | 두 객체 깊은 비교 | 변경 여부 판단 | - -```typescript -// 예시: 저장 시 차이값만 추출 -const defaults = { showHeader: true, pageSize: 20 }; -const fullConfig = { showHeader: true, pageSize: 50, customField: "test" }; -const overrides = extractCustomConfig(fullConfig, defaults); -// 결과: { pageSize: 50, customField: "test" } (차이값만!) -``` - -#### URL 처리 함수 - -| 함수명 | 역할 | 예시 | -|--------|------|------| -| `getComponentUrl()` | 타입 → URL 변환 | `"v2-table-list"` → `"@/lib/registry/components/v2-table-list"` | -| `getComponentTypeFromUrl()` | URL → 타입 추출 | `"@/lib/registry/components/v2-table-list"` → `"v2-table-list"` | - -#### 기본값 조회 함수 - -| 함수명 | 역할 | -|--------|------| -| `getComponentDefaults()` | 컴포넌트 타입으로 기본값 조회 | -| `getDefaultsByUrl()` | URL로 기본값 조회 | - -#### V2 로드/저장 함수 (핵심!) - -| 함수명 | 역할 | 사용 시점 | -|--------|------|----------| -| `loadComponentV2()` | 컴포넌트 로드 (기본값 병합) | DB → 화면 | -| `saveComponentV2()` | 컴포넌트 저장 (차이값 추출) | 화면 → DB | -| `loadLayoutV2()` | 레이아웃 전체 로드 | DB → 화면 | -| `saveLayoutV2()` | 레이아웃 전체 저장 | 화면 → DB | - -#### 변환 함수 - -| 함수명 | 역할 | -|--------|------| -| `convertV2ToLegacy()` | V2 → Legacy 변환 (하위 호환) | -| `convertLegacyToV2()` | Legacy → V2 변환 | -| `isValidV2Layout()` | V2 레이아웃인지 검증 | -| `isLegacyLayout()` | 레거시 레이아웃인지 확인 | - -### 8-2. 공통 Zod 스키마 - -**파일 위치:** `frontend/lib/schemas/componentConfig.ts` - -#### 핵심 스키마 (필수!) - -```typescript -// 컴포넌트 기본 구조 -export const componentV2Schema = z.object({ - id: z.string(), - url: z.string(), - position: z.object({ x: z.number(), y: z.number() }), - size: z.object({ width: z.number(), height: z.number() }), - displayOrder: z.number().default(0), - overrides: z.record(z.string(), z.any()).default({}), -}); - -// 레이아웃 기본 구조 -export const layoutV2Schema = z.object({ - version: z.string().default("2.0"), - components: z.array(componentV2Schema).default([]), - updatedAt: z.string().optional(), - screenResolution: z.object({...}).optional(), - gridSettings: z.any().optional(), -}); -``` - -#### 컴포넌트별 overrides 스키마 (25개+) - -| 스키마명 | 컴포넌트 | 주요 기본값 | -|----------|----------|------------| -| `v2TableListOverridesSchema` | 테이블 리스트 | displayMode: "table", pageSize: 20 | -| `v2ButtonPrimaryOverridesSchema` | 버튼 | text: "저장", variant: "primary" | -| `v2SplitPanelLayoutOverridesSchema` | 분할 레이아웃 | splitRatio: 30, resizable: true | -| `v2SectionCardOverridesSchema` | 섹션 카드 | padding: "md", collapsible: false | -| `v2TabsWidgetOverridesSchema` | 탭 위젯 | orientation: "horizontal" | -| `v2RepeaterOverridesSchema` | 리피터 | renderMode: "inline" | - -#### 스키마 레지스트리 (자동 매핑) - -```typescript -const componentOverridesSchemaRegistry = { - "v2-table-list": v2TableListOverridesSchema, - "v2-button-primary": v2ButtonPrimaryOverridesSchema, - "v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema, - // ... 25개+ 컴포넌트 -}; -``` - -### 8-3. 공통 타입 정의 - -**파일 위치:** `frontend/types/v2-core.ts`, `frontend/types/v2-components.ts` - -#### 핵심 공통 타입 (v2-core.ts) - -```typescript -// 웹 입력 타입 -export type WebType = - | "text" | "textarea" | "email" | "tel" | "url" - | "number" | "decimal" - | "date" | "datetime" - | "select" | "dropdown" | "radio" | "checkbox" | "boolean" - | "code" | "entity" | "file" | "image" | "button" - | "container" | "group" | "list" | "tree" | "custom"; - -// 버튼 액션 타입 -export type ButtonActionType = - | "save" | "cancel" | "delete" | "edit" | "copy" | "add" - | "search" | "reset" | "submit" - | "close" | "popup" | "modal" - | "navigate" | "newWindow" - | "control" | "transferData" | "quickInsert"; - -// 위치/크기 -export interface Position { x: number; y: number; z?: number; } -export interface Size { width: number; height: number; } - -// 공통 스타일 -export interface CommonStyle { - margin?: string; - padding?: string; - border?: string; - backgroundColor?: string; - color?: string; - fontSize?: string; - // ... 30개+ 속성 -} - -// 유효성 검사 -export interface ValidationRule { - type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max" | "email" | "url"; - value?: unknown; - message: string; -} -``` - -#### V2 컴포넌트 타입 (v2-components.ts) - -```typescript -// 10개 통합 컴포넌트 타입 -export type V2ComponentType = - | "V2Input" | "V2Select" | "V2Date" | "V2Text" | "V2Media" - | "V2List" | "V2Layout" | "V2Group" | "V2Biz" | "V2Hierarchy"; - -// 공통 속성 -export interface V2BaseProps { - id: string; - label?: string; - required?: boolean; - readonly?: boolean; - disabled?: boolean; - tableName?: string; - columnName?: string; - position?: Position; - size?: Size; - style?: CommonStyle; - validation?: ValidationRule[]; -} -``` - -### 8-4. POP 통합 시 공유/분리 기준 - -#### 반드시 공유 (그대로 사용) - -| 구분 | 파일/요소 | 이유 | -|------|----------|------| -| **유틸리티** | `deepMerge`, `extractCustomConfig`, `mergeComponentConfig` | 저장/로드 로직 동일 | -| **스키마** | `componentV2Schema`, `layoutV2Schema` | JSON 구조 동일 | -| **타입** | `Position`, `Size`, `WebType`, `ButtonActionType` | 기본 구조 동일 | - -#### POP 전용으로 분리 - -| 구분 | 파일/요소 | 이유 | -|------|----------|------| -| **overrides 스키마** | `popCardListOverridesSchema` 등 | POP 컴포넌트 전용 기본값 | -| **스키마 레지스트리** | `popComponentOverridesSchemaRegistry` | POP 컴포넌트 매핑 | -| **기본값 레지스트리** | `popComponentDefaultsRegistry` | POP 컴포넌트 기본값 | - -### 8-5. 추천 폴더 구조 (공유 분리) - -``` -frontend/lib/schemas/ -├── componentConfig.ts # 기존 (데스크톱) -├── popComponentConfig.ts # 신규 (POP) - 구조는 동일 -└── shared/ # 신규 (공유) - 향후 통합 시 - ├── baseSchemas.ts # componentV2Schema, layoutV2Schema - ├── mergeUtils.ts # deepMerge, extractCustomConfig 등 - └── types.ts # Position, Size 등 -``` - ---- - -## 9. 작업 우선순위 - -### [ ] 1단계: 데이터베이스 - -- [ ] `screen_layouts_pop` 테이블 생성 마이그레이션 작성 -- [ ] 마이그레이션 실행 및 검증 - -### [ ] 2단계: 백엔드 API - -- [ ] `screenManagementService.ts`에 POP 함수 추가 - - [ ] `getScreenLayoutPop()` - - [ ] `saveLayoutPop()` - - [ ] `deleteLayoutPop()` -- [ ] `screenManagementRoutes.ts`에 엔드포인트 추가 - - [ ] `GET /screens/:screenId/layout-pop` - - [ ] `POST /screens/:screenId/layout-pop` - - [ ] `DELETE /screens/:screenId/layout-pop` - -### [ ] 3단계: 프론트엔드 기반 - -- [ ] `lib/api/screen.ts`에 POP API 함수 추가 - - [ ] `getLayoutPop()` - - [ ] `saveLayoutPop()` -- [ ] `lib/registry/PopComponentRegistry.ts` 생성 -- [ ] `lib/schemas/popComponentConfig.ts` 생성 -- [ ] `lib/utils/layoutPopConverter.ts` 생성 - -### [ ] 4단계: POP 컴포넌트 개발 - -- [ ] `lib/registry/pop-components/` 폴더 구조 생성 -- [ ] 기본 컴포넌트 개발 - - [ ] `pop-card-list` (카드형 리스트) - - [ ] `pop-touch-button` (터치 버튼) - - [ ] `pop-scanner-input` (스캐너 입력) - - [ ] `pop-status-badge` (상태 배지) - -### [ ] 5단계: POP 화면 페이지 - -- [ ] `app/(pop)/screens/[screenId]/page.tsx` 생성 -- [ ] `components/pop/PopDynamicRenderer.tsx` 생성 -- [ ] `app/(pop)/layout.tsx` 수정 (POP 전용 스타일) - -### [ ] 6단계: POP 화면 디자이너 (선택) - -- [ ] `components/pop/PopScreenDesigner.tsx` 생성 -- [ ] `components/pop/PopPreview.tsx` 생성 -- [ ] 관리자 메뉴에 POP 화면 설계 기능 추가 - ---- - -## 10. 참고 파일 위치 - -### 데스크톱 참고 파일 (기존) - -| 구분 | 파일 경로 | -|------|----------| -| 화면 페이지 | `frontend/app/(main)/screens/[screenId]/page.tsx` | -| 컴포넌트 레지스트리 | `frontend/lib/registry/ComponentRegistry.ts` | -| 동적 렌더러 | `frontend/lib/registry/DynamicComponentRenderer.tsx` | -| Zod 스키마 | `frontend/lib/schemas/componentConfig.ts` | -| 레이아웃 변환기 | `frontend/lib/utils/layoutV2Converter.ts` | -| 화면 API | `frontend/lib/api/screen.ts` | -| 백엔드 서비스 | `backend-node/src/services/screenManagementService.ts` | -| 백엔드 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | - -### 관련 문서 - -| 문서 | 경로 | -|------|------| -| V2 아키텍처 | `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | -| 화면관리 설계 | `docs/kjs/화면관리_시스템_설계.md` | - ---- - -## 11. 주의사항 - -### 멀티테넌시 - -- 모든 테이블에 `company_code` 필수 -- 모든 쿼리에 `company_code` 필터링 적용 -- 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능 - -### 충돌 방지 - -- 기존 데스크톱 파일 수정 최소화 -- POP 전용 폴더/파일에서 작업 -- 공통 로직은 별도 유틸리티로 분리 - -### 테스트 - -- 데스크톱 기능 회귀 테스트 필수 -- POP 반응형 테스트 (모바일/태블릿) -- 멀티테넌시 격리 테스트 - ---- - -## 변경 이력 - -| 날짜 | 버전 | 내용 | -|------|------|------| -| 2026-01-29 | 1.0 | 초기 계획서 작성 | -| 2026-01-29 | 1.1 | V2 공통 요소 (통합 핵심) 섹션 추가 | - ---- - -## 작성자 - -- 작성일: 2026-01-29 -- 프로젝트: Vexplor POP 화면 시스템 diff --git a/popdocs/archive/POPUPDATE.md b/popdocs/archive/POPUPDATE.md deleted file mode 100644 index 836cdb1f..00000000 --- a/popdocs/archive/POPUPDATE.md +++ /dev/null @@ -1,1041 +0,0 @@ -# POP 화면 관리 시스템 개발 기록 - -> **AI 에이전트 안내**: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다. -> 1. 먼저 [Quick Reference](#quick-reference)에서 필요한 정보 확인 -> 2. 상세 내용이 필요하면 해당 섹션으로 이동 -> 3. 코드가 필요하면 파일 직접 참조 - ---- - -## 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 | [아키텍처](#1-아키텍처) | 레이아웃 테이블로 POP/데스크톱 분리 | -| 2 | [데이터베이스](#2-데이터베이스) | screen_layouts_pop 테이블 (FK 없음) | -| 3 | [백엔드 API](#3-백엔드-api) | CRUD 4개 엔드포인트 | -| 4 | [프론트엔드 API](#4-프론트엔드-api) | screenApi에 4개 함수 추가 | -| 5 | [관리 페이지](#5-관리-페이지) | POP 화면만 필터링하여 표시 | -| 6 | [뷰어](#6-뷰어) | 모바일/태블릿 프레임 미리보기 | -| 7 | [미리보기](#7-미리보기) | isPop prop으로 URL 분기 | -| 8 | [파일 목록](#8-파일-목록) | 생성 3개, 수정 9개 | -| 9 | [반응형 전략](#9-반응형-전략-신규-결정사항) | Flow 레이아웃 (세로 쌓기) 채택 | -| 10 | [POP 사용자 앱](#10-pop-사용자-앱-구조-신규-결정사항) | 대시보드 카드 → 화면 뷰어 | -| 11 | [POP 디자이너](#11-pop-디자이너-신규-계획) | 좌(탭패널) + 우(팬캔버스), 반응형 편집 | -| 12 | [데이터 구조](#12-pop-레이아웃-데이터-구조-신규) | PopLayoutData, mobileOverride | -| 13 | [컴포넌트 재사용성](#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` - -```typescript -screenApi.getLayoutPop(screenId) // 조회 -screenApi.saveLayoutPop(screenId, data) // 저장 -screenApi.deleteLayoutPop(screenId) // 삭제 -screenApi.getScreenIdsWithPopLayout() // ID 목록 -``` - ---- - -## 5. 관리 페이지 - -**파일**: `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` - -**핵심 로직**: -```typescript -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 레이아웃 데이터 구조 -```typescript -{ - 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` 미리 추가 -- 버튼: 툴바에 자리만 (비활성) -- 연결: 추후 `MultilangSettingsModal` import - -### 데스크톱 시스템 재사용 -| 기능 | 재사용 | 비고 | -|-----|-------|------| -| 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 -```typescript -interface PopLayoutData { - version: "pop-1.0"; - layoutMode: "flow"; // 항상 flow (절대좌표 없음) - deviceTarget: "mobile" | "tablet" | "both"; - components: PopComponentData[]; -} -``` - -### PopComponentData -```typescript -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; -} -``` - -### 데스크톱 vs POP 데이터 비교 -| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) | -|-----|----------------------|---------------------| -| 배치 | `position: { x, y, z }` | `order: number` | -| 크기 | `size: { width, height }` (픽셀) | `size: "S" | "M" | "L"` (프리셋) | -| 컨테이너 | 없음 (자유 배치) | `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로 컴포넌트 배치) -``` - -**문제점:** -1. 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨 -2. 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만) -3. 사용자가 두 가지 단위를 이해해야 함 - -### 변경: 단일 자동계산 그리드 - -**핵심 변경사항:** -- 그리드 점(dot) 제거 -- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산 -- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈 - -**코드 (SectionGrid.tsx):** -```typescript -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는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨 - -**해결:** -```typescript -// 변경 전 - - -// 변경 후 - -``` - -상태 업데이트는 드래그/리사이즈 완료 후에만 실행 - ---- - -## POP 화면 관리 페이지 개발 (2026-02-02) - -### POP 카테고리 트리 API 구현 - -**기능:** -- POP 화면을 카테고리별로 관리하는 트리 구조 구현 -- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 -- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 - -**백엔드 API:** -- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 -- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 -- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 -- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 -- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 - -### 트러블슈팅: API 경로 중복 문제 - -**문제:** 카테고리 생성 시 404 에러 발생 - -**원인:** -- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 -- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 - -**해결:** -```typescript -// 변경 전 -const response = await apiClient.post("/api/screen-groups/pop/groups", data); - -// 변경 후 -const response = await apiClient.post("/screen-groups/pop/groups", data); -``` - -### 트러블슈팅: created_by 컬럼 오류 - -**문제:** `column "created_by" of relation "screen_groups" does not exist` - -**원인:** -- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 -- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., created_by) VALUES (..., $9) - --- 변경 후 -INSERT INTO screen_groups (..., writer) VALUES (..., $9) -``` - -### 트러블슈팅: is_active 컬럼 타입 불일치 - -**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 - -**원인:** -- `is_active` 컬럼이 `VARCHAR(1)` 타입 -- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., is_active) VALUES (..., true) - --- 변경 후 -INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') -``` - -**교훈:** -- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 -- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 -- `created_by` 대신 `writer` 컬럼명 사용 - -### 카테고리 트리 UI 개선 - -**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 - -**해결:** -1. 들여쓰기 증가: `level * 16px` → `level * 24px` -2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 -3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 - -```tsx -// 하위 레벨에 연결 표시 추가 -{level > 0 && ( - -)} - -// 루트와 하위 폴더 시각적 구분 - -{group.group_name} -``` - -### 미분류 화면 이동 기능 추가 - -**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 - -**구현:** -```tsx -// 이동 드롭다운 메뉴 - - - - - - {treeData.map((g) => ( - handleMoveScreenToGroup(screen, g)}> - - {g.group_name} - - ))} - - - -// API 호출 (apiClient 사용) -const handleMoveScreenToGroup = async (screen, group) => { - await apiClient.post("/screen-groups/group-screens", { - group_id: group.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); -}; -``` - -**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 - -### 화면 이동 로직 수정 (복사 → 이동) - -**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 - -**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 - -**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 - -```tsx -const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { - // 1. 기존 연결 찾기 및 삭제 - for (const g of groups) { - const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); - if (existingLink) { - await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); - break; - } - } - - // 2. 새 그룹에 연결 추가 - await apiClient.post("/screen-groups/group-screens", { - group_id: targetGroup.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); - - loadGroups(); // 목록 새로고침 -}; -``` - -### 화면/카테고리 메뉴 UI 개선 - -**변경 사항:** -1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) -2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 -3. 폴더 메뉴에도 위로/아래로 이동 추가 - -**순서 변경 구현:** -```tsx -// 그룹 순서 변경 (display_order 교환) -const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { - const siblingGroups = groups - .filter((g) => g.parent_id === targetGroup.parent_id) - .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); - - const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); - if (currentIndex <= 0) return; - - const prevGroup = siblingGroups[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), - apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), - ]); - - loadGroups(); -}; - -// 화면 순서 변경 (screen_group_screens의 display_order 교환) -const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { - const targetGroup = groups.find((g) => g.id === groupId); - const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); - const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); - - if (currentIndex <= 0) return; - - const currentLink = sortedScreens[currentIndex]; - const prevLink = sortedScreens[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), - apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), - ]); - - loadGroups(); -}; -``` - -### 카테고리 이동 모달 (서브메뉴 → 모달 방식) - -**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 - -**해결:** 검색 기능이 있는 모달로 변경 - -**구현:** -```tsx -// 이동 모달 상태 -const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); -const [movingScreen, setMovingScreen] = useState(null); -const [movingFromGroupId, setMovingFromGroupId] = useState(null); -const [moveSearchTerm, setMoveSearchTerm] = useState(""); - -// 필터링된 그룹 목록 -const filteredMoveGroups = useMemo(() => { - if (!moveSearchTerm) return flattenedGroups; - const searchLower = moveSearchTerm.toLowerCase(); - return flattenedGroups.filter((g) => - (g._displayName || g.group_name).toLowerCase().includes(searchLower) - ); -}, [flattenedGroups, moveSearchTerm]); - -// 모달 UI 특징: -// 1. 검색 입력창 (Search 아이콘 포함) -// 2. 트리 구조 표시 (depth에 따라 들여쓰기) -// 3. 현재 소속 그룹 표시 및 선택 불가 처리 -// 4. ScrollArea로 긴 목록 스크롤 지원 -``` - -**모달 구조:** -``` -┌─────────────────────────────┐ -│ 카테고리로 이동 │ -│ "화면명" 화면을 이동할... │ -├─────────────────────────────┤ -│ 🔍 카테고리 검색... │ -├─────────────────────────────┤ -│ 📁 POP 화면 │ -│ 📁 홈 관리 │ -│ 📁 출고관리 │ -│ 📁 수주관리 │ -│ 📁 생산 관리 (현재) │ -├─────────────────────────────┤ -│ [ 취소 ] │ -└─────────────────────────────┘ -``` - ---- - -## 14. 비율 기반 그리드 시스템 (2026-02-03) - -### 문제 발견 - -POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생. - -### 근본 원인 분석 - -1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀) -2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장 -3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음) -4. **비교**: 가로(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 || 24` fallback으로 처리 -- 기존 `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* diff --git a/popdocs/archive/POP_V4_CONSTRAINT_SYSTEM_PLAN.md b/popdocs/archive/POP_V4_CONSTRAINT_SYSTEM_PLAN.md deleted file mode 100644 index a0331e73..00000000 --- a/popdocs/archive/POP_V4_CONSTRAINT_SYSTEM_PLAN.md +++ /dev/null @@ -1,760 +0,0 @@ -# POP v4.0 제약조건 기반 시스템 구현 계획 - -## 1. 현재 시스템 분석 - -### 1.1 현재 구조 (v3.0) - -```typescript -// 4개 모드별 그리드 위치 기반 -interface PopLayoutDataV3 { - version: "pop-3.0"; - layouts: { - tablet_landscape: { componentPositions: Record }; - tablet_portrait: { componentPositions: Record }; - mobile_landscape: { componentPositions: Record }; - mobile_portrait: { componentPositions: Record }; - }; - components: Record; - // ... -} - -interface GridPosition { - col: number; // 1-based - row: number; // 1-based - colSpan: number; - rowSpan: number; -} -``` - -### 1.2 현재 문제점 - -1. **4배 작업량**: 4개 모드 각각 설계 필요 -2. **수동 동기화**: 컴포넌트 추가/삭제 시 4모드 수동 동기화 -3. **그리드 한계**: col/row 기반이라 자동 재배치 불가 -4. **반응형 미흡**: 화면 크기 변화에 자동 적응 불가 -5. **디바이스 차이 무시**: 태블릿/모바일 물리적 크기 차이 고려 안됨 - ---- - -## 2. 새로운 시스템 설계 (v4.0) - -### 2.1 핵심 철학 - -``` -"하나의 레이아웃 설계 → 제약조건 설정 → 모든 화면 자동 적응" -``` - -- **단일 소스**: 1개 레이아웃만 설계 -- **제약조건 기반**: 컴포넌트가 "어떻게 반응할지" 규칙 정의 -- **Flexbox 렌더링**: CSS Grid에서 Flexbox 기반으로 전환 -- **자동 줄바꿈**: 공간 부족 시 자동 재배치 - -### 2.2 새로운 데이터 구조 - -```typescript -// v4.0 레이아웃 -interface PopLayoutDataV4 { - version: "pop-4.0"; - - // 루트 컨테이너 - root: PopContainer; - - // 컴포넌트 정의 (ID → 정의) - components: Record; - - // 데이터 흐름 - dataFlow: PopDataFlow; - - // 전역 설정 - settings: PopGlobalSettingsV4; - - // 메타데이터 - metadata?: PopLayoutMetadata; -} -``` - -### 2.3 컨테이너 (스택) - -```typescript -// 컨테이너: 컴포넌트들을 담는 그룹 -interface PopContainer { - id: string; - type: "stack"; - - // 스택 방향 - direction: "horizontal" | "vertical"; - - // 줄바꿈 허용 - wrap: boolean; - - // 요소 간 간격 - gap: number; - - // 정렬 - alignItems: "start" | "center" | "end" | "stretch"; - justifyContent: "start" | "center" | "end" | "space-between" | "space-around"; - - // 패딩 - padding?: { - top: number; - right: number; - bottom: number; - left: number; - }; - - // 반응형 규칙 (선택) - responsive?: { - // 브레이크포인트 (이 너비 이하에서 적용) - breakpoint: number; - // 변경할 방향 - direction?: "horizontal" | "vertical"; - // 변경할 간격 - gap?: number; - }[]; - - // 자식 요소 (컴포넌트 ID 또는 중첩 컨테이너) - children: (string | PopContainer)[]; -} -``` - -### 2.4 컴포넌트 제약조건 - -```typescript -interface PopComponentDefinitionV4 { - id: string; - type: PopComponentType; - label?: string; - - // ===== 크기 제약 (핵심) ===== - size: { - // 너비 모드 - width: "fixed" | "fill" | "hug"; - // 높이 모드 - height: "fixed" | "fill" | "hug"; - - // 고정 크기 (width/height가 fixed일 때) - fixedWidth?: number; - fixedHeight?: number; - - // 최소/최대 크기 - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - - // 비율 (fill일 때, 같은 컨테이너 내 다른 요소와의 비율) - flexGrow?: number; // 기본 1 - flexShrink?: number; // 기본 1 - }; - - // ===== 정렬 ===== - alignSelf?: "start" | "center" | "end" | "stretch"; - - // ===== 여백 ===== - margin?: { - top: number; - right: number; - bottom: number; - left: number; - }; - - // ===== 모바일 스케일 (선택) ===== - // 모바일에서 컴포넌트를 더 크게 표시 - mobileScale?: number; // 기본 1.0, 예: 1.2 = 20% 더 크게 - - // ===== 기존 속성 ===== - dataBinding?: PopDataBinding; - style?: PopStylePreset; - config?: PopComponentConfig; -} -``` - -### 2.5 크기 모드 설명 - -| 모드 | 설명 | CSS 변환 | -|------|------|----------| -| `fixed` | 고정 크기 (px) | `width: {fixedWidth}px` | -| `fill` | 부모 공간 채우기 | `flex: {flexGrow} {flexShrink} 0` | -| `hug` | 내용에 맞춤 | `flex: 0 0 auto` | - -### 2.6 전역 설정 - -```typescript -interface PopGlobalSettingsV4 { - // 기본 터치 타겟 크기 - touchTargetMin: number; // 48px - - // 모드 (일반/산업현장) - mode: "normal" | "industrial"; - - // 기본 간격 - defaultGap: number; // 8px - - // 기본 패딩 - defaultPadding: number; // 16px - - // 반응형 브레이크포인트 (전역) - breakpoints: { - tablet: number; // 768px - mobile: number; // 480px - }; -} -``` - ---- - -## 3. 디자이너 UI 변경 - -### 3.1 기존 디자이너 vs 새 디자이너 - -``` -기존 (그리드 기반): -┌──────────────────────────────────────────────┐ -│ [태블릿 가로] [태블릿 세로] [모바일 가로] [모바일 세로] │ -│ │ -│ 24x24 그리드에 컴포넌트 드래그 배치 │ -│ │ -└──────────────────────────────────────────────┘ - -새로운 (제약조건 기반): -┌──────────────────────────────────────────────┐ -│ [단일 캔버스] 미리보기: [태블릿▼] │ -│ │ -│ 스택(컨테이너)에 컴포넌트 배치 │ -│ + 우측 패널에서 제약조건 설정 │ -│ │ -└──────────────────────────────────────────────┘ -``` - -### 3.2 새로운 디자이너 레이아웃 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ POP 화면 디자이너 v4 [저장] [미리보기] │ -├────────────────┬────────────────────────┬───────────────────────┤ -│ │ │ │ -│ 컴포넌트 │ 캔버스 │ 속성 패널 │ -│ │ │ │ -│ ▼ 기본 │ ┌──────────────────┐ │ ▼ 선택됨: 입력창 │ -│ [필드] │ │ ┌──────────────┐ │ │ │ -│ [버튼] │ │ │입력창 │ │ │ ▼ 크기 │ -│ [리스트] │ │ └──────────────┘ │ │ 너비: [채우기 ▼] │ -│ [인디케이터] │ │ │ │ 최소: [100] px │ -│ │ │ ┌─────┐ ┌─────┐ │ │ 최대: [없음] │ -│ ▼ 입력 │ │ │버튼1│ │버튼2│ │ │ │ -│ [스캐너] │ │ └─────┘ └─────┘ │ │ 높이: [고정 ▼] │ -│ [숫자패드] │ │ │ │ 값: [48] px │ -│ │ └──────────────────┘ │ │ -│ ───────── │ │ ▼ 정렬 │ -│ │ 미리보기: │ [늘이기 ▼] │ -│ ▼ 레이아웃 │ ┌──────────────────┐ │ │ -│ [스택 (가로)] │ │[태블릿 가로 ▼] │ │ ▼ 여백 │ -│ [스택 (세로)] │ │[768px] │ │ 상[8] 우[0] 하[8] 좌[0]│ -│ │ └──────────────────┘ │ │ -│ │ │ ▼ 반응형 │ -│ │ │ 모바일 스케일: [1.2] │ -│ │ │ │ -└────────────────┴────────────────────────┴───────────────────────┘ -``` - -### 3.3 컨테이너(스택) 편집 - -``` -┌─ 스택 속성 ─────────────────────┐ -│ │ -│ 방향: [가로 ▼] │ -│ 줄바꿈: [허용 ☑] │ -│ 간격: [8] px │ -│ │ -│ 정렬 (가로): [가운데 ▼] │ -│ 정렬 (세로): [늘이기 ▼] │ -│ │ -│ ▼ 반응형 규칙 │ -│ ┌─────────────────────────────┐ │ -│ │ 768px 이하: 세로 방향 │ │ -│ │ [+ 규칙 추가] │ │ -│ └─────────────────────────────┘ │ -│ │ -└─────────────────────────────────┘ -``` - ---- - -## 4. 렌더링 로직 변경 - -### 4.1 기존 렌더링 (CSS Grid) - -```typescript -// v3: CSS Grid 기반 -
- {componentIds.map(id => ( -
- -
- ))} -
-``` - -### 4.2 새로운 렌더링 (Flexbox) - -```typescript -// v4: Flexbox 기반 -function renderContainer(container: PopContainer, components: Record) { - const direction = useResponsiveValue(container, 'direction'); - const gap = useResponsiveValue(container, 'gap'); - - return ( -
- {container.children.map(child => { - if (typeof child === "string") { - // 컴포넌트 렌더링 - return renderComponent(components[child]); - } else { - // 중첩 컨테이너 렌더링 - return renderContainer(child, components); - } - })} -
- ); -} - -function renderComponent(component: PopComponentDefinitionV4) { - const { size, margin, mobileScale } = component; - const isMobile = useIsMobile(); - const scale = isMobile && mobileScale ? mobileScale : 1; - - // 크기 계산 - let width: string; - let flex: string; - - if (size.width === "fixed") { - width = `${(size.fixedWidth || 100) * scale}px`; - flex = "0 0 auto"; - } else if (size.width === "fill") { - width = "auto"; - flex = `${size.flexGrow || 1} ${size.flexShrink || 1} 0`; - } else { // hug - width = "auto"; - flex = "0 0 auto"; - } - - return ( -
- -
- ); -} -``` - -### 4.3 반응형 훅 - -```typescript -function useResponsiveValue( - container: PopContainer, - property: keyof PopContainer -): T { - const windowWidth = useWindowWidth(); - - // 기본값 - let value = container[property] as T; - - // 반응형 규칙 적용 (작은 브레이크포인트 우선) - if (container.responsive) { - const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint); - for (const rule of sortedRules) { - if (windowWidth <= rule.breakpoint && rule[property] !== undefined) { - value = rule[property] as T; - } - } - } - - return value; -} -``` - ---- - -## 5. 구현 단계 - -### Phase 1: 데이터 구조 (1-2일) - -**파일**: `frontend/components/pop/designer/types/pop-layout.ts` - -1. `PopLayoutDataV4` 인터페이스 정의 -2. `PopContainer` 인터페이스 정의 -3. `PopComponentDefinitionV4` 인터페이스 정의 -4. `createEmptyPopLayoutV4()` 함수 -5. `migrateV3ToV4()` 마이그레이션 함수 -6. `ensureV4Layout()` 함수 -7. 타입 가드 함수들 - -### Phase 2: 렌더러 (2-3일) - -**파일**: `frontend/components/pop/designer/renderers/PopLayoutRendererV4.tsx` - -1. `renderContainer()` 함수 -2. `renderComponent()` 함수 -3. `useResponsiveValue()` 훅 -4. `useWindowWidth()` 훅 -5. CSS 스타일 계산 로직 -6. 반응형 브레이크포인트 처리 - -### Phase 3: 디자이너 UI (3-4일) - -**파일**: `frontend/components/pop/designer/PopDesignerV4.tsx` - -1. 캔버스 영역 (드래그 앤 드롭) -2. 컴포넌트 팔레트 (기존 + 스택) -3. 속성 패널 - - 크기 제약 편집 - - 정렬 편집 - - 여백 편집 - - 반응형 규칙 편집 -4. 미리보기 모드 (다양한 화면 크기) -5. 컨테이너(스택) 관리 - - 컨테이너 추가/삭제 - - 컨테이너 설정 편집 - - 컴포넌트 이동 (컨테이너 간) - -### Phase 4: 뷰어 통합 (1-2일) - -**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` - -1. v4 레이아웃 감지 및 렌더링 -2. 기존 v3 호환 유지 -3. 반응형 모드 감지 연동 -4. 성능 최적화 - -### Phase 5: 백엔드 수정 (1일) - -**파일**: `backend-node/src/services/screenManagementService.ts` - -1. `saveLayoutPop` - v4 버전 감지 및 저장 -2. `getLayoutPop` - v4 버전 반환 -3. 버전 마이그레이션 로직 - -### Phase 6: 테스트 및 마이그레이션 (2-3일) - -1. 단위 테스트 -2. 통합 테스트 -3. 기존 v3 레이아웃 마이그레이션 도구 -4. 크로스 디바이스 테스트 - ---- - -## 6. 마이그레이션 전략 - -### 6.1 v3 → v4 자동 변환 - -```typescript -function migrateV3ToV4(v3: PopLayoutDataV3): PopLayoutDataV4 { - // 태블릿 가로 모드 기준으로 변환 - const baseLayout = v3.layouts.tablet_landscape; - const componentIds = Object.keys(baseLayout.componentPositions); - - // 컴포넌트를 row, col 순으로 정렬 - const sortedIds = componentIds.sort((a, b) => { - const posA = baseLayout.componentPositions[a]; - const posB = baseLayout.componentPositions[b]; - if (posA.row !== posB.row) return posA.row - posB.row; - return posA.col - posB.col; - }); - - // 같은 row에 있는 컴포넌트들을 가로 스택으로 그룹화 - const rowGroups = groupByRow(sortedIds, baseLayout.componentPositions); - - // 루트 컨테이너 (세로 스택) - const rootContainer: PopContainer = { - id: "root", - type: "stack", - direction: "vertical", - wrap: false, - gap: v3.settings.canvasGrid.gap, - alignItems: "stretch", - justifyContent: "start", - children: [], - }; - - // 각 행을 가로 스택으로 변환 - for (const [row, ids] of rowGroups) { - if (ids.length === 1) { - // 단일 컴포넌트면 직접 추가 - rootContainer.children.push(ids[0]); - } else { - // 여러 컴포넌트면 가로 스택으로 감싸기 - const rowStack: PopContainer = { - id: `row-${row}`, - type: "stack", - direction: "horizontal", - wrap: true, - gap: v3.settings.canvasGrid.gap, - alignItems: "center", - justifyContent: "start", - children: ids, - }; - rootContainer.children.push(rowStack); - } - } - - // 컴포넌트 정의 변환 - const components: Record = {}; - for (const id of componentIds) { - const v3Comp = v3.components[id]; - const pos = baseLayout.componentPositions[id]; - - components[id] = { - ...v3Comp, - size: { - // colSpan을 기반으로 크기 모드 결정 - width: pos.colSpan >= 20 ? "fill" : "fixed", - height: "fixed", - fixedWidth: pos.colSpan * (1024 / 24), // 대략적인 픽셀 변환 - fixedHeight: pos.rowSpan * (768 / 24), - minWidth: 100, - }, - }; - } - - return { - version: "pop-4.0", - root: rootContainer, - components, - dataFlow: v3.dataFlow, - settings: { - touchTargetMin: v3.settings.touchTargetMin, - mode: v3.settings.mode, - defaultGap: v3.settings.canvasGrid.gap, - defaultPadding: 16, - breakpoints: { - tablet: 768, - mobile: 480, - }, - }, - metadata: v3.metadata, - }; -} -``` - -### 6.2 하위 호환 - -- v3 레이아웃은 계속 지원 -- 디자이너에서 v3 → v4 업그레이드 버튼 제공 -- 새로 생성하는 레이아웃은 v4 - ---- - -## 7. 예상 효과 - -### 7.1 사용자 경험 - -| 항목 | 기존 (v3) | 새로운 (v4) | -|------|-----------|-------------| -| 설계 개수 | 4개 | 1개 | -| 작업 시간 | 4배 | 1배 | -| 반응형 | 수동 | 자동 | -| 디바이스 대응 | 각각 설정 | mobileScale | - -### 7.2 개발자 경험 - -| 항목 | 기존 (v3) | 새로운 (v4) | -|------|-----------|-------------| -| 렌더링 | CSS Grid | Flexbox | -| 위치 계산 | col/row | 자동 | -| 반응형 로직 | 4모드 분기 | 브레이크포인트 | -| 유지보수 | 복잡 | 단순 | - ---- - -## 8. 일정 (예상) - -| Phase | 내용 | 기간 | -|-------|------|------| -| 1 | 데이터 구조 | 1-2일 | -| 2 | 렌더러 | 2-3일 | -| 3 | 디자이너 UI | 3-4일 | -| 4 | 뷰어 통합 | 1-2일 | -| 5 | 백엔드 수정 | 1일 | -| 6 | 테스트/마이그레이션 | 2-3일 | -| **총계** | | **10-15일** | - ---- - -## 9. 리스크 및 대응 - -### 9.1 기존 레이아웃 호환성 - -- **리스크**: v3 → v4 자동 변환이 완벽하지 않을 수 있음 -- **대응**: - - 마이그레이션 미리보기 기능 - - 수동 조정 도구 제공 - - v3 유지 옵션 - -### 9.2 학습 곡선 - -- **리스크**: 제약조건 개념이 익숙하지 않을 수 있음 -- **대응**: - - 프리셋 제공 (예: "화면 전체 채우기", "고정 크기") - - 툴팁/도움말 - - 예제 템플릿 - -### 9.3 성능 - -- **리스크**: Flexbox 중첩으로 렌더링 성능 저하 -- **대응**: - - 컨테이너 중첩 깊이 제한 (최대 3-4) - - React.memo 활용 - - 가상화 (리스트 컴포넌트) - ---- - -## 10. 결론 - -v4.0 제약조건 기반 시스템은 업계 표준(Figma, Flutter, SwiftUI)을 따르며, 사용자의 작업량을 75% 줄이고 자동 반응형을 제공합니다. - -구현 후 POP 디자이너는: -- **1개 레이아웃**만 설계 -- **모든 화면 크기**에 자동 적응 -- **모바일 특화 설정** (mobileScale)으로 세밀한 제어 가능 - ---- - -## 11. 추가 설정 (2026-02-03 업데이트) - -### 11.1 확장된 전역 설정 - -```typescript -interface PopGlobalSettingsV4 { - // 기존 - touchTargetMin: number; // 48 (normal) / 60 (industrial) - mode: "normal" | "industrial"; - defaultGap: number; - defaultPadding: number; - breakpoints: { - tablet: number; // 768 - mobile: number; // 480 - }; - - // 신규 추가 - environment: "indoor" | "outdoor"; // 야외면 대비 높임 - - typography: { - body: { min: number; max: number }; // 14-18px - heading: { min: number; max: number }; // 18-28px - caption: { min: number; max: number }; // 12-14px - }; - - contrast: "normal" | "high"; // outdoor면 자동 high -} -``` - -### 11.2 컴포넌트 기본값 프리셋 - -컴포넌트 추가 시 자동 적용되는 안전한 기본값: - -```typescript -const COMPONENT_DEFAULTS = { - "pop-button": { - minWidth: 80, - minHeight: 48, - height: "fixed", - fixedHeight: 48, - }, - "pop-field": { - minWidth: 120, - minHeight: 40, - height: "fixed", - fixedHeight: 48, - }, - "pop-list": { - minHeight: 200, - itemHeight: 48, - }, - // ... -}; -``` - -### 11.3 리스트 반응형 컬럼 - -```typescript -interface PopListConfig { - // 기존 - listType: PopListType; - displayColumns?: string[]; - - // 신규 추가 - responsiveColumns?: { - tablet: string[]; // 전체 컬럼 - mobile: string[]; // 주요 컬럼만 - }; -} -``` - -### 11.4 라벨 배치 자동화 - -```typescript -interface PopContainer { - // 기존 - direction: "horizontal" | "vertical"; - - // 신규 추가 - labelPlacement?: "auto" | "above" | "beside"; - // auto: 모바일 세로=위, 태블릿 가로=옆 -} -``` - ---- - -## 12. 관련 문서 - -- [v4 핵심 규칙 가이드](./V4_CORE_RULES.md) - **3가지 핵심 규칙 (필독)** -- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md) -- [컴포넌트 로드맵](./COMPONENT_ROADMAP.md) -- [크기 프리셋 가이드](./SIZE_PRESETS.md) -- [컴포넌트 상세 스펙](./components-spec.md) - ---- - -## 13. 현재 상태 (2026-02-03) - -**구현 대기**: 컴포넌트가 아직 없어서 레이아웃 시스템보다 컴포넌트 개발이 선행되어야 함. - -**권장 진행 순서**: -1. 기초 컴포넌트 개발 (PopButton, PopInput 등) -2. 조합 컴포넌트 개발 (PopFormField, PopCard 등) -3. 복합 컴포넌트 개발 (PopDataTable, PopCardList 등) -4. v4 레이아웃 시스템 구현 -5. 디자이너 UI 개발 - ---- - -*최종 업데이트: 2026-02-03* diff --git a/popdocs/archive/PROJECT_ARCHITECTURE.md b/popdocs/archive/PROJECT_ARCHITECTURE.md deleted file mode 100644 index cb3752fd..00000000 --- a/popdocs/archive/PROJECT_ARCHITECTURE.md +++ /dev/null @@ -1,285 +0,0 @@ -# VEXPLOR (WACE 솔루션) 프로젝트 아키텍처 - -> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조 - ---- - -## Quick Reference - -### 기술 스택 요약 - -| 영역 | 기술 | -|------|------| -| Frontend | Next.js 14, TypeScript, shadcn/ui, Tailwind CSS | -| Backend | Node.js + Express (주력), Java Spring (레거시) | -| Database | PostgreSQL (173개 테이블) | -| 핵심 기능 | 노코드 화면 빌더, 멀티테넌시, 워크플로우 | - -### 디렉토리 맵 - -``` -ERP-node/ -├── frontend/ # Next.js -│ ├── app/ # 라우팅 (main, auth, pop) -│ ├── components/ # UI 컴포넌트 (281개+) -│ └── lib/ # API, 레지스트리 (463개) -├── backend-node/ # Node.js 백엔드 -│ └── src/ -│ ├── controllers/ # 68개 -│ ├── services/ # 78개 -│ └── routes/ # 47개 -├── db/ # 마이그레이션 -└── docs/ # 문서 -``` - -### 핵심 테이블 - -| 분류 | 테이블 | -|------|--------| -| 화면 | screen_definitions, screen_layouts_v2, screen_layouts_pop | -| 메뉴 | menu_info, authority_master | -| 사용자 | user_info, company_mng | -| 플로우 | flow_definition, flow_step | - ---- - -## 1. 프로젝트 개요 - -### 1.1 제품 소개 -- **WACE 솔루션**: PLM(Product Lifecycle Management) + 노코드 화면 빌더 -- **멀티테넌시**: company_code 기반 회사별 데이터 격리 -- **마이그레이션**: JSP에서 Next.js로 완전 전환 - -### 1.2 핵심 기능 -1. **Screen Designer**: 드래그앤드롭 화면 구성 -2. **워크플로우**: 플로우 기반 업무 자동화 -3. **배치 시스템**: 스케줄 기반 작업 자동화 -4. **외부 연동**: DB, REST API 통합 -5. **리포트**: 동적 보고서 생성 -6. **다국어**: i18n 지원 - ---- - -## 2. Frontend 구조 - -### 2.1 라우트 그룹 - -``` -app/ -├── (main)/ # 메인 레이아웃 -│ ├── admin/ # 관리자 기능 -│ │ ├── screenMng/ # 화면 관리 -│ │ ├── systemMng/ # 시스템 관리 -│ │ ├── userMng/ # 사용자 관리 -│ │ └── automaticMng/ # 자동화 관리 -│ ├── screens/[screenId]/ # 동적 화면 뷰어 -│ └── dashboard/[id]/ # 대시보드 뷰어 -├── (auth)/ # 인증 -│ └── login/ -└── (pop)/ # POP 전용 - └── pop/screens/[screenId]/ -``` - -### 2.2 주요 컴포넌트 - -| 폴더 | 파일 수 | 역할 | -|------|---------|------| -| screen/ | 70+ | 화면 디자이너, 위젯 | -| admin/ | 137 | 테이블, 메뉴, 코드 관리 | -| dataflow/ | 101 | 노드 기반 플로우 에디터 | -| dashboard/ | 32 | 대시보드 빌더 | -| v2/ | 20+ | V2 컴포넌트 시스템 | -| pop/ | 26 | POP 전용 컴포넌트 | - -### 2.3 라이브러리 (lib/) - -``` -lib/ -├── api/ # API 클라이언트 (50개+) -│ ├── screen.ts # 화면 API -│ ├── menu.ts # 메뉴 API -│ └── flow.ts # 플로우 API -├── registry/ # 컴포넌트 레지스트리 (463개) -│ ├── DynamicComponentRenderer.tsx -│ └── components/ -├── v2-core/ # Zod 기반 타입 시스템 -└── utils/ # 유틸리티 (30개+) -``` - ---- - -## 3. Backend 구조 - -### 3.1 디렉토리 - -``` -backend-node/src/ -├── controllers/ # 68개 컨트롤러 -├── services/ # 78개 서비스 -├── routes/ # 47개 라우트 -├── middleware/ # 인증, 에러 처리 -├── database/ # DB 연결 -├── types/ # 26개 타입 정의 -└── utils/ # 16개 유틸 -``` - -### 3.2 주요 서비스 - -| 영역 | 서비스 | -|------|--------| -| 화면 | screenManagementService, layoutService | -| 데이터 | dataService, tableManagementService | -| 플로우 | flowDefinitionService, flowExecutionService | -| 배치 | batchService, batchSchedulerService | -| 외부연동 | externalDbConnectionService, externalCallService | - -### 3.3 API 엔드포인트 - -``` -# 화면 관리 -GET /api/screen-management/screens -GET /api/screen-management/screen/:id -POST /api/screen-management/screen -PUT /api/screen-management/screen/:id -GET /api/screen-management/layout-v2/:screenId -POST /api/screen-management/layout-v2/:screenId -GET /api/screen-management/layout-pop/:screenId -POST /api/screen-management/layout-pop/:screenId - -# 데이터 CRUD -GET /api/data/:tableName -POST /api/data/:tableName -PUT /api/data/:tableName/:id -DELETE /api/data/:tableName/:id - -# 인증 -POST /api/auth/login -POST /api/auth/logout -GET /api/auth/me -``` - ---- - -## 4. Database 구조 - -### 4.1 테이블 분류 (173개) - -| 분류 | 개수 | 주요 테이블 | -|------|------|-------------| -| 화면/레이아웃 | 15 | screen_definitions, screen_layouts_v2, screen_layouts_pop | -| 메뉴/권한 | 7 | menu_info, authority_master, rel_menu_auth | -| 사용자/회사 | 6 | user_info, company_mng, dept_info | -| 테이블/컬럼 | 8 | table_type_columns, column_labels | -| 다국어 | 4 | multi_lang_key_master, multi_lang_text | -| 플로우/배치 | 12 | flow_definition, flow_step, batch_configs | -| 외부연동 | 4 | external_db_connections, external_call_configs | -| 리포트 | 5 | report_master, report_layout, report_query | -| 대시보드 | 2 | dashboards, dashboard_elements | -| 컴포넌트 | 6 | component_standards, web_type_standards | -| 비즈니스 | 100+ | item_info, sales_order_mng, inventory_stock | - -### 4.2 화면 관련 테이블 상세 - -```sql --- 화면 정의 -screen_definitions: screen_id, screen_name, table_name, company_code - --- 데스크톱 레이아웃 (V2, 현재 사용) -screen_layouts_v2: id, screen_id, components(JSONB), grid_settings - --- POP 레이아웃 -screen_layouts_pop: id, screen_id, components(JSONB), grid_settings - --- 화면 그룹 -screen_groups: group_id, group_name, company_code -screen_group_screens: id, group_id, screen_id -``` - -### 4.3 멀티테넌시 - -```sql --- 모든 테이블에 company_code 필수 -ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20) NOT NULL; - --- 모든 쿼리에 company_code 필터링 필수 -SELECT * FROM example_table WHERE company_code = $1; - --- 예외: company_mng (회사 마스터 테이블) -``` - ---- - -## 5. 핵심 기능 상세 - -### 5.1 노코드 Screen Designer - -**아키텍처**: -``` -screen_definitions (화면 정의) - ↓ -screen_layouts_v2 (레이아웃, JSONB) - ↓ -DynamicComponentRenderer (동적 렌더링) - ↓ -registry/components (컴포넌트 라이브러리) -``` - -**컴포넌트 레지스트리**: -- V2 컴포넌트: Input, Select, Table, Button 등 -- 위젯: FlowWidget, CategoryWidget 등 -- 레이아웃: SplitPanel, TabPanel 등 - -### 5.2 워크플로우 (Flow) - -**테이블 구조**: -``` -flow_definition (플로우 정의) - ↓ -flow_step (단계 정의) - ↓ -flow_step_connection (단계 연결) - ↓ -flow_data_status (데이터 상태 추적) -``` - -### 5.3 POP 시스템 - -**별도 레이아웃 테이블**: -- `screen_layouts_pop`: POP 전용 레이아웃 -- 모바일/태블릿 반응형 지원 -- 제조 현장 최적화 컴포넌트 - ---- - -## 6. 개발 환경 - -### 6.1 로컬 개발 - -```bash -# Docker 실행 -docker-compose -f docker-compose.win.yml up -d - -# 프론트엔드: http://localhost:9771 -# 백엔드: http://localhost:8080 -``` - -### 6.2 데이터베이스 - -``` -Host: 39.117.244.52 -Port: 11132 -Database: plm -Username: postgres -``` - ---- - -## 7. 관련 문서 - -- [POPUPDATE.md](../POPUPDATE.md): POP 개발 기록 -- [docs/pop/components-spec.md](pop/components-spec.md): POP 컴포넌트 설계 -- [.cursorrules](../.cursorrules): 개발 가이드라인 - ---- - -*최종 업데이트: 2026-01-29* diff --git a/popdocs/archive/RESPONSIVE_DESIGN_GUIDE.md b/popdocs/archive/RESPONSIVE_DESIGN_GUIDE.md deleted file mode 100644 index 05cf044f..00000000 --- a/popdocs/archive/RESPONSIVE_DESIGN_GUIDE.md +++ /dev/null @@ -1,153 +0,0 @@ -# POP 반응형 디자인 가이드 - -## 쉬운 요약 - -### 핵심 원칙: 3가지만 기억하세요 - -``` -1. 누르는 것 → 크기 고정 (최소 48px) -2. 읽는 것 → 범위 안에서 자동 조절 -3. 담는 것 → 화면에 맞춰 늘어남 -``` - ---- - -## 1. 터치 요소 (고정 크기) - -손가락 크기는 화면이 커져도 변하지 않습니다. - -| 요소 | 일반 | 산업현장(장갑) | -|------|-----|--------------| -| 버튼 | 48px | 60px | -| 아이콘 (누르는 용) | 48px | 60px | -| 체크박스 | 24px (터치영역 48px) | 24px (터치영역 60px) | -| 리스트 한 줄 높이 | 48px | 56px | -| 입력창 높이 | 40px | 48px | - ---- - -## 2. 텍스트 (범위 조절) - -화면 크기에 따라 자동으로 커지거나 작아집니다. - -| 용도 | 최소 | 최대 | -|------|-----|-----| -| 본문 | 14px | 18px | -| 제목 | 18px | 28px | -| 설명 | 12px | 14px | - -**CSS 예시**: -```css -font-size: clamp(14px, 1.5vw, 18px); -``` - ---- - -## 3. 레이아웃 (비율 기반) - -컨테이너는 화면에 맞춰 늘어납니다. - -| 요소 | 방식 | 예시 | -|------|-----|-----| -| 컨테이너 | 100% | 화면 전체 채움 | -| 카드 2열 | 48% + 48% | 화면 반씩 | -| 입력창 너비 | fill | 부모 채움 | -| 여백 | 8/16/24px | 화면 크기별 | - ---- - -## 4. 환경별 설정 - -### 일반 (실내) -- 터치: 48px -- 대비: 4.5:1 -- 폰트: 14-18px - -### 산업현장 (야외/장갑) -- 터치: 60px (+25%) -- 대비: 7:1 이상 -- 폰트: 18-22px (+25%) - ---- - -## 5. 리스트/테이블 반응형 - -화면이 좁아지면 컬럼을 줄입니다. - -``` -태블릿 (넓음) 모바일 (좁음) -┌──────┬──────┬──────┬──────┐ ┌──────┬──────┐ -│품번 │품명 │수량 │상태 │ │품번 │수량 │ -├──────┼──────┼──────┼──────┤ ├──────┼──────┤ -│A001 │나사 │100 │완료 │ │A001 │100 │ -└──────┴──────┴──────┴──────┘ └──────┴──────┘ - ↳ 터치하면 상세보기 -``` - ---- - -## 6. 폼 라벨 배치 - -| 화면 | 라벨 위치 | 이유 | -|------|----------|-----| -| 모바일 세로 | 위 | 입력창 너비 확보 | -| 태블릿 가로 | 옆 | 공간 여유 | - -``` -모바일 태블릿 -┌─────────────────┐ ┌─────────────────────────┐ -│ 이름 │ │ 이름: [입력____________] │ -│ [입력__________]│ └─────────────────────────┘ -└─────────────────┘ -``` - ---- - -## 7. 그림으로 보는 반응형 - -``` -8인치 태블릿 12인치 태블릿 -┌─────────────────┐ ┌───────────────────────┐ -│ [버튼 48px] │ │ [버튼 48px] │ ← 버튼 크기 동일! -│ │ │ │ -│ ┌─────────────┐ │ │ ┌─────────────────┐ │ -│ │ 입력창 │ │ │ │ 입력창 │ │ ← 너비만 늘어남 -│ └─────────────┘ │ │ └─────────────────┘ │ -│ │ │ │ -│ 글자 14px │ │ 글자 18px │ ← 글자만 커짐 -└─────────────────┘ └───────────────────────┘ -``` - ---- - -## 8. 색상 대비 (야외용) - -| 환경 | 최소 대비 | 권장 | -|------|----------|-----| -| 실내 | 4.5:1 | 7:1 | -| 야외 | 7:1 | 10:1+ | - -**좋은 조합**: -- 흰 배경 + 검정 글자 -- 검정 배경 + 흰 글자 -- 노랑 경고 + 검정 글자 - -**피해야 할 조합**: -- 연한 회색 + 밝은 회색 -- 빨강 + 녹색 (색맹 고려) - ---- - -## 체크리스트 - -### 컴포넌트 만들 때 확인 - -- [ ] 버튼/터치 요소 최소 48px인가? -- [ ] 폰트에 clamp() 적용했나? -- [ ] 색상 대비 4.5:1 이상인가? -- [ ] 모바일에서 라벨이 위에 있나? -- [ ] 리스트가 좁은 화면에서 컬럼 줄어드나? - ---- - -*최종 업데이트: 2026-02-03* diff --git a/popdocs/archive/SIZE_PRESETS.md b/popdocs/archive/SIZE_PRESETS.md deleted file mode 100644 index 99de0691..00000000 --- a/popdocs/archive/SIZE_PRESETS.md +++ /dev/null @@ -1,205 +0,0 @@ -# POP 크기 프리셋 가이드 - -## 컴포넌트별 기본 크기 - -컴포넌트를 만들면 자동으로 적용되는 크기입니다. - ---- - -## 버튼 (PopButton) - -| 사이즈 | 높이 | 최소 너비 | 폰트 | 용도 | -|-------|------|----------|------|-----| -| sm | 32px | 60px | 12px | 보조 버튼 | -| md | 40px | 80px | 14px | 일반 버튼 | -| **lg** | **48px** | **100px** | **16px** | **POP 기본** | -| xl | 56px | 120px | 18px | 주요 액션 | -| industrial | 60px | 140px | 20px | 장갑 착용 | - -```typescript -// POP에서는 lg가 기본 -확인 - -// 산업현장 -작업 완료 -``` - ---- - -## 입력창 (PopInput) - -| 사이즈 | 높이 | 폰트 | 용도 | -|-------|------|------|-----| -| md | 40px | 14px | 일반 | -| **lg** | **48px** | **16px** | **POP 기본** | -| xl | 56px | 18px | 강조 입력 | - -```typescript -// 입력창 너비는 항상 부모 채움 (fill) - -``` - ---- - -## 리스트 행 (PopListItem) - -| 사이즈 | 높이 | 폰트 | 용도 | -|-------|------|------|-----| -| compact | 40px | 14px | 많은 데이터 | -| **normal** | **48px** | **16px** | **POP 기본** | -| spacious | 56px | 18px | 여유로운 | -| industrial | 64px | 20px | 장갑 착용 | - -```typescript - - 작업지시 #1234 - -``` - ---- - -## 아이콘 (PopIcon) - -| 사이즈 | 크기 | 터치 영역 | 용도 | -|-------|-----|----------|-----| -| sm | 16px | 32px | 뱃지 안 | -| md | 20px | 40px | 텍스트 옆 | -| **lg** | **24px** | **48px** | **POP 기본** | -| xl | 32px | 56px | 강조 | - -```typescript -// 아이콘만 있는 버튼 - - -// 텍스트 + 아이콘 -저장 -``` - ---- - -## 카드 (PopCard) - -| 요소 | 크기 | -|------|-----| -| 패딩 | 16px | -| 제목 폰트 | 18px (heading) | -| 본문 폰트 | 16px (body) | -| 모서리 | 8px | -| 최소 높이 | 100px | - -```typescript - - 작업지시 - 내용 - -``` - ---- - -## 숫자패드 (PopNumberPad) - -| 요소 | 크기 | -|------|-----| -| 버튼 크기 | 60px x 60px | -| 버튼 간격 | 8px | -| 전체 너비 | 240px | -| 폰트 | 24px | - -``` -┌─────────────────────┐ -│ [ 123 ] │ ← 디스플레이 48px -├─────┬─────┬─────────┤ -│ 7 │ 8 │ 9 │ ← │ ← 각 버튼 60x60 -├─────┼─────┼─────────┤ -│ 4 │ 5 │ 6 │ C │ -├─────┼─────┼─────────┤ -│ 1 │ 2 │ 3 │ │ -├─────┼─────┼─────│ OK│ -│ 0 │ . │ +- │ │ -└─────┴─────┴─────────┘ -``` - ---- - -## 상태 표시 (PopStatusBox) - -| 사이즈 | 너비 | 높이 | 아이콘 | 용도 | -|-------|-----|------|-------|-----| -| sm | 80px | 60px | 24px | 여러 개 나열 | -| **md** | **120px** | **80px** | **32px** | **POP 기본** | -| lg | 160px | 100px | 40px | 강조 | - -```typescript - -``` - ---- - -## KPI 게이지 (PopKpiGauge) - -| 사이즈 | 너비 | 높이 | 용도 | -|-------|-----|------|-----| -| sm | 120px | 120px | 여러 개 나열 | -| **md** | **180px** | **180px** | **POP 기본** | -| lg | 240px | 240px | 강조 | - ---- - -## 간격 (Gap/Padding) - -| 이름 | 값 | 용도 | -|------|---|-----| -| xs | 4px | 아이콘-텍스트 | -| sm | 8px | 요소 내부 | -| **md** | **16px** | **컴포넌트 간** | -| lg | 24px | 섹션 간 | -| xl | 32px | 영역 구분 | - ---- - -## 반응형 조절 - -화면 크기에 따라 자동 조절되는 값들: - -| 요소 | 8인치 태블릿 | 12인치 태블릿 | -|------|------------|--------------| -| 본문 폰트 | 14px | 18px | -| 제목 폰트 | 18px | 28px | -| 컨테이너 패딩 | 12px | 24px | -| 카드 간격 | 12px | 16px | - -**고정되는 값들 (변하지 않음)**: -- 버튼 높이: 48px -- 입력창 높이: 48px -- 리스트 행 높이: 48px -- 터치 최소 영역: 48px - ---- - -## 적용 예시 - -```typescript -// 컴포넌트 내부에서 자동 적용 -function PopButton({ size = "lg", ...props }) { - const sizeStyles = { - sm: { height: 32, minWidth: 60, fontSize: 12 }, - md: { height: 40, minWidth: 80, fontSize: 14 }, - lg: { height: 48, minWidth: 100, fontSize: 16 }, // POP 기본 - xl: { height: 56, minWidth: 120, fontSize: 18 }, - industrial: { height: 60, minWidth: 140, fontSize: 20 }, - }; - - return ( - + )} +
+ { + const newValues = [...formula.values]; + newValues[index] = { ...fv, dataSource: ds }; + onChange({ ...formula, values: newValues }); + }} + /> + + ))} + + {/* 값 추가 */} + + + {/* 수식 입력 */} +
+ + + onChange({ ...formula, expression: e.target.value }) + } + placeholder="예: A / B * 100" + className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`} + /> + {!isValid && ( +

+ 수식에 정의되지 않은 변수가 있습니다 +

+ )} +
+ + {/* 표시 형태 */} +
+ + +
+ + ); +} + +// ===== 아이템 편집기 ===== + +function ItemEditor({ + item, + index, + onUpdate, + onDelete, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}: { + item: DashboardItem; + index: number; + onUpdate: (item: DashboardItem) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const [dataMode, setDataMode] = useState<"single" | "formula">( + item.formula?.enabled ? "formula" : "single" + ); + + return ( +
+ {/* 헤더 */} +
+ + + {item.label || `아이템 ${index + 1}`} + + + {SUBTYPE_LABELS[item.subType]} + + + {/* 이동 버튼 */} + + + + {/* 보이기/숨기기 */} + + onUpdate({ ...item, visible: checked }) + } + className="scale-75" + /> + + {/* 접기/펼치기 */} + + + {/* 삭제 */} + +
+ + {/* 상세 설정 (접힘) */} + {expanded && ( +
+ {/* 라벨 */} +
+ + onUpdate({ ...item, label: e.target.value })} + className="h-8 text-xs" + placeholder="아이템 이름" + /> +
+ + {/* 서브타입 */} +
+ + +
+ + {/* 데이터 모드 선택 */} +
+ + +
+ + {/* 데이터 소스 / 수식 편집 */} + {dataMode === "formula" && item.formula ? ( + onUpdate({ ...item, formula: f })} + /> + ) : ( + onUpdate({ ...item, dataSource: ds })} + /> + )} + + {/* 요소별 보이기/숨기기 */} +
+ +
+ {( + [ + ["showLabel", "라벨"], + ["showValue", "값"], + ["showUnit", "단위"], + ["showTrend", "증감율"], + ["showSubLabel", "보조라벨"], + ["showTarget", "목표값"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ + {/* 서브타입별 추가 설정 */} + {item.subType === "kpi-card" && ( +
+ + + onUpdate({ + ...item, + kpiConfig: { ...item.kpiConfig, unit: e.target.value }, + }) + } + placeholder="EA, 톤, 원" + className="h-8 text-xs" + /> +
+ )} + + {item.subType === "chart" && ( +
+ + +
+ )} + + {item.subType === "gauge" && ( +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: parseInt(e.target.value) || 0, + max: item.gaugeConfig?.max ?? 100, + ...item.gaugeConfig, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: item.gaugeConfig?.min ?? 0, + max: parseInt(e.target.value) || 100, + ...item.gaugeConfig, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + min: item.gaugeConfig?.min ?? 0, + max: item.gaugeConfig?.max ?? 100, + ...item.gaugeConfig, + target: parseInt(e.target.value) || undefined, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ )} +
+ )} +
+ ); +} + +// ===== 그리드 레이아웃 편집기 ===== + +function GridLayoutEditor({ + cells, + gridColumns, + gridRows, + items, + onChange, +}: { + cells: DashboardCell[]; + gridColumns: number; + gridRows: number; + items: DashboardItem[]; + onChange: ( + cells: DashboardCell[], + cols: number, + rows: number + ) => void; +}) { + // 셀이 없으면 기본 그리드 생성 + const ensuredCells = + cells.length > 0 + ? cells + : Array.from({ length: gridColumns * gridRows }, (_, i) => ({ + id: `cell-${i}`, + gridColumn: `${(i % gridColumns) + 1} / ${(i % gridColumns) + 2}`, + gridRow: `${Math.floor(i / gridColumns) + 1} / ${Math.floor(i / gridColumns) + 2}`, + itemId: null as string | null, + })); + + return ( +
+ {/* 열/행 수 */} +
+
+ + { + const newCols = Math.max(1, parseInt(e.target.value) || 1); + onChange(ensuredCells, newCols, gridRows); + }} + className="h-8 text-xs" + min={1} + max={6} + /> +
+
+ + { + const newRows = Math.max(1, parseInt(e.target.value) || 1); + onChange(ensuredCells, gridColumns, newRows); + }} + className="h-8 text-xs" + min={1} + max={6} + /> +
+
+ + {/* 셀 미리보기 + 아이템 배정 */} +
+ {ensuredCells.map((cell) => ( +
+ +
+ ))} +
+ + {/* 셀 재생성 */} + +
+ ); +} + +// ===== 메인 설정 패널 ===== + +export function PopDashboardConfigPanel({ + config, + onChange, +}: ConfigPanelProps) { + const cfg = config ?? DEFAULT_CONFIG; + const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( + "basic" + ); + + // 설정 변경 헬퍼 + const updateConfig = useCallback( + (partial: Partial) => { + onChange({ ...cfg, ...partial }); + }, + [cfg, onChange] + ); + + // 아이템 추가 + const addItem = useCallback( + (subType: DashboardSubType) => { + const newItem: DashboardItem = { + id: `item-${Date.now()}`, + label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`, + visible: true, + subType, + dataSource: { ...DEFAULT_DATASOURCE }, + visibility: { ...DEFAULT_VISIBILITY }, + }; + updateConfig({ items: [...cfg.items, newItem] }); + }, + [cfg.items, updateConfig] + ); + + // 아이템 업데이트 + const updateItem = useCallback( + (index: number, item: DashboardItem) => { + const newItems = [...cfg.items]; + newItems[index] = item; + updateConfig({ items: newItems }); + }, + [cfg.items, updateConfig] + ); + + // 아이템 삭제 (grid 셀 배정도 해제) + const deleteItem = useCallback( + (index: number) => { + const deletedId = cfg.items[index].id; + const newItems = cfg.items.filter((_, i) => i !== index); + + // grid 셀에서 해당 아이템 배정 해제 + const newCells = cfg.gridCells?.map((cell) => + cell.itemId === deletedId ? { ...cell, itemId: null } : cell + ); + + updateConfig({ items: newItems, gridCells: newCells }); + }, + [cfg.items, cfg.gridCells, updateConfig] + ); + + // 아이템 순서 변경 + const moveItem = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= cfg.items.length) return; + const newItems = [...cfg.items]; + const [moved] = newItems.splice(from, 1); + newItems.splice(to, 0, moved); + updateConfig({ items: newItems }); + }, + [cfg.items, updateConfig] + ); + + return ( +
+ {/* 탭 헤더 */} +
+ {( + [ + ["basic", "기본 설정"], + ["items", "아이템"], + ["layout", "레이아웃"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ + {/* ===== 기본 설정 탭 ===== */} + {activeTab === "basic" && ( +
+ {/* 표시 모드 */} +
+ + +
+ + {/* 자동 슬라이드 설정 */} + {cfg.displayMode === "auto-slide" && ( +
+
+ + + updateConfig({ + autoSlideInterval: parseInt(e.target.value) || 5, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ + + updateConfig({ + autoSlideResumeDelay: parseInt(e.target.value) || 3, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ )} + + {/* 인디케이터 */} +
+ + + updateConfig({ showIndicator: checked }) + } + /> +
+ + {/* 간격 */} +
+ + + updateConfig({ gap: parseInt(e.target.value) || 8 }) + } + className="h-8 text-xs" + min={0} + /> +
+ + {/* 배경색 */} +
+ + + updateConfig({ backgroundColor: e.target.value || undefined }) + } + placeholder="예: #f0f0f0" + className="h-8 text-xs" + /> +
+
+ )} + + {/* ===== 아이템 관리 탭 ===== */} + {activeTab === "items" && ( +
+ {/* 아이템 목록 */} + {cfg.items.map((item, index) => ( + updateItem(index, updated)} + onDelete={() => deleteItem(index)} + onMoveUp={() => moveItem(index, index - 1)} + onMoveDown={() => moveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === cfg.items.length - 1} + /> + ))} + + {/* 아이템 추가 버튼 */} +
+ {(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map( + (subType) => ( + + ) + )} +
+
+ )} + + {/* ===== 레이아웃 탭 (grid 모드 전용) ===== */} + {activeTab === "layout" && ( +
+ {cfg.displayMode === "grid" ? ( + + updateConfig({ + gridCells: cells, + gridColumns: cols, + gridRows: rows, + }) + } + /> + ) : ( +
+

+ 그리드 모드에서만 레이아웃을 편집할 수 있습니다. +
+ 기본 설정에서 표시 모드를 "그리드"로 변경하세요. +

+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx new file mode 100644 index 00000000..db64f04c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * pop-dashboard 디자이너 미리보기 컴포넌트 + * + * 실제 데이터 없이 더미 레이아웃으로 미리보기 표시 + * 디자이너가 설정 변경 시 즉시 미리보기 확인 가능 + */ + +import React from "react"; +import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; +import type { PopDashboardConfig, DashboardSubType } from "../types"; + +// ===== 서브타입별 아이콘 매핑 ===== + +const SUBTYPE_ICONS: Record = { + "kpi-card": , + chart: , + gauge: , + "stat-card": , +}; + +const SUBTYPE_LABELS: Record = { + "kpi-card": "KPI", + chart: "차트", + gauge: "게이지", + "stat-card": "통계", +}; + +// ===== 모드 라벨 ===== + +const MODE_LABELS: Record = { + arrows: "좌우 버튼", + "auto-slide": "자동 슬라이드", + grid: "그리드", + scroll: "스크롤", +}; + +// ===== 더미 아이템 프리뷰 ===== + +function DummyItemPreview({ + subType, + label, +}: { + subType: DashboardSubType; + label: string; +}) { + return ( +
+ + {SUBTYPE_ICONS[subType]} + + + {label || SUBTYPE_LABELS[subType]} + +
+ ); +} + +// ===== 메인 미리보기 ===== + +export function PopDashboardPreviewComponent({ + config, +}: { + config?: PopDashboardConfig; +}) { + if (!config || !config.items.length) { + return ( +
+ + 대시보드 +
+ ); + } + + const visibleItems = config.items.filter((i) => i.visible); + const mode = config.displayMode; + + return ( +
+ {/* 모드 표시 */} +
+ + {MODE_LABELS[mode] ?? mode} + + + {visibleItems.length}개 + +
+ + {/* 모드별 미리보기 */} +
+ {mode === "grid" ? ( + // 그리드: 셀 구조 시각화 +
+ {config.gridCells?.length + ? config.gridCells.map((cell) => { + const item = visibleItems.find( + (i) => i.id === cell.itemId + ); + return ( +
+ {item ? ( + + ) : ( +
+ )} +
+ ); + }) + : // 셀 미설정: 아이템만 나열 + visibleItems.slice(0, 4).map((item) => ( + + ))} +
+ ) : ( + // 다른 모드: 첫 번째 아이템만 크게 표시 +
+ {visibleItems[0] && ( + + )} + {/* 추가 아이템 수 뱃지 */} + {visibleItems.length > 1 && ( +
+ +{visibleItems.length - 1} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx new file mode 100644 index 00000000..01a653b6 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx @@ -0,0 +1,34 @@ +"use client"; + +/** + * pop-dashboard 컴포넌트 레지스트리 등록 진입점 + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopDashboardComponent } from "./PopDashboardComponent"; +import { PopDashboardConfigPanel } from "./PopDashboardConfig"; +import { PopDashboardPreviewComponent } from "./PopDashboardPreview"; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-dashboard", + name: "대시보드", + description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌", + category: "display", + icon: "BarChart3", + component: PopDashboardComponent, + configPanel: PopDashboardConfigPanel, + preview: PopDashboardPreviewComponent, + defaultProps: { + items: [], + displayMode: "arrows", + autoSlideInterval: 5, + autoSlideResumeDelay: 3, + showIndicator: true, + gap: 8, + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx new file mode 100644 index 00000000..66694a58 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * 차트 서브타입 컴포넌트 + * + * Recharts 기반 막대/원형/라인 차트 + * 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지 + */ + +import React from "react"; +import { + BarChart, + Bar, + PieChart, + Pie, + Cell, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import type { DashboardItem } from "../../types"; + +// ===== Props ===== + +export interface ChartItemProps { + item: DashboardItem; + /** 차트에 표시할 데이터 행 */ + rows: Record[]; + /** 컨테이너 너비 (px) - 최소 크기 판단용 */ + containerWidth: number; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#06b6d4", // cyan + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // rose + "#ec4899", // pink + "#14b8a6", // teal +]; + +// ===== 최소 표시 크기 ===== + +const MIN_CHART_WIDTH = 120; + +// ===== 메인 컴포넌트 ===== + +export function ChartItemComponent({ + item, + rows, + containerWidth, +}: ChartItemProps) { + const { chartConfig, visibility } = item; + const chartType = chartConfig?.chartType ?? "bar"; + const colors = chartConfig?.colors?.length + ? chartConfig.colors + : DEFAULT_COLORS; + const xKey = chartConfig?.xAxisColumn ?? "name"; + const yKey = chartConfig?.yAxisColumn ?? "value"; + + // 컨테이너가 너무 작으면 메시지 표시 + if (containerWidth < MIN_CHART_WIDTH) { + return ( +
+ 차트 +
+ ); + } + + // 데이터 없음 + if (!rows.length) { + return ( +
+ 데이터 없음 +
+ ); + } + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 차트 영역 */} +
+ + {chartType === "bar" ? ( + []}> + + + + + + ) : chartType === "line" ? ( + []}> + + + + 250} + /> + + ) : ( + /* pie */ + + []} + dataKey={yKey} + nameKey={xKey} + cx="50%" + cy="50%" + outerRadius="80%" + label={containerWidth > 250} + > + {rows.map((_, index) => ( + + ))} + + + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx new file mode 100644 index 00000000..e2b5dd30 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -0,0 +1,137 @@ +"use client"; + +/** + * 게이지 서브타입 컴포넌트 + * + * SVG 기반 반원형 게이지 (외부 라이브러리 불필요) + * min/max/target/current 표시, 달성률 구간별 색상 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface GaugeItemProps { + item: DashboardItem; + data: number | null; + /** 동적 목표값 (targetDataSource로 조회된 값) */ + targetValue?: number | null; +} + +// ===== 게이지 색상 판정 ===== + +function getGaugeColor( + percentage: number, + ranges?: { min: number; max: number; color: string }[] +): string { + if (ranges?.length) { + const match = ranges.find((r) => percentage >= r.min && percentage <= r.max); + if (match) return match.color; + } + // 기본 색상 (달성률 기준) + if (percentage >= 80) return "#10b981"; // emerald + if (percentage >= 50) return "#f59e0b"; // amber + return "#ef4444"; // rose +} + +// ===== 메인 컴포넌트 ===== + +export function GaugeItemComponent({ + item, + data, + targetValue, +}: GaugeItemProps) { + const { visibility, gaugeConfig } = item; + const current = data ?? 0; + const min = gaugeConfig?.min ?? 0; + const max = gaugeConfig?.max ?? 100; + const target = targetValue ?? gaugeConfig?.target ?? max; + + // 달성률 계산 (0~100) + const range = max - min; + const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0; + const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges); + + // SVG 반원 게이지 수치 + const cx = 100; + const cy = 90; + const radius = 70; + // 반원: 180도 -> percentage에 비례한 각도 + const startAngle = Math.PI; // 180도 (왼쪽) + const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향 + + const startX = cx + radius * Math.cos(startAngle); + const startY = cy - radius * Math.sin(startAngle); + const endX = cx + radius * Math.cos(endAngle); + const endY = cy - radius * Math.sin(endAngle); + const largeArcFlag = percentage > 50 ? 1 : 0; + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 게이지 SVG */} +
+ + {/* 배경 반원 (회색) */} + + + {/* 값 반원 (색상) */} + {percentage > 0 && ( + + )} + + {/* 중앙 텍스트 */} + {visibility.showValue && ( + + {abbreviateNumber(current)} + + )} + + {/* 퍼센트 */} + + {percentage.toFixed(1)}% + + +
+ + {/* 목표값 */} + {visibility.showTarget && ( +

+ 목표: {abbreviateNumber(target)} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx new file mode 100644 index 00000000..1cb09e74 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -0,0 +1,111 @@ +"use client"; + +/** + * KPI 카드 서브타입 컴포넌트 + * + * 큰 숫자 + 단위 + 증감 표시 + * CSS Container Query로 반응형 내부 콘텐츠 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface KpiCardProps { + item: DashboardItem; + data: number | null; + /** 이전 기간 대비 증감 퍼센트 (선택) */ + trendValue?: number | null; + /** 수식 결과 표시 문자열 (formula가 있을 때) */ + formulaDisplay?: string | null; +} + +// ===== 증감 표시 ===== + +function TrendIndicator({ value }: { value: number }) { + const isPositive = value > 0; + const isZero = value === 0; + const color = isPositive + ? "text-emerald-600" + : isZero + ? "text-muted-foreground" + : "text-rose-600"; + const arrow = isPositive ? "↑" : isZero ? "→" : "↓"; + + return ( + + {arrow} + {Math.abs(value).toFixed(1)}% + + ); +} + +// ===== 색상 구간 판정 ===== + +function getColorForValue( + value: number, + ranges?: { min: number; max: number; color: string }[] +): string | undefined { + if (!ranges?.length) return undefined; + const match = ranges.find((r) => value >= r.min && value <= r.max); + return match?.color; +} + +// ===== 메인 컴포넌트 ===== + +export function KpiCardComponent({ + item, + data, + trendValue, + formulaDisplay, +}: KpiCardProps) { + const { visibility, kpiConfig } = item; + const displayValue = data ?? 0; + const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 메인 값 */} + {visibility.showValue && ( +
+ + {formulaDisplay ?? abbreviateNumber(displayValue)} + + + {/* 단위 */} + {visibility.showUnit && kpiConfig?.unit && ( + + {kpiConfig.unit} + + )} +
+ )} + + {/* 증감율 */} + {visibility.showTrend && trendValue != null && ( +
+ +
+ )} + + {/* 보조 라벨 (수식 표시 등) */} + {visibility.showSubLabel && formulaDisplay && ( +

+ {item.formula?.values.map((v) => v.label).join(" / ")} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx new file mode 100644 index 00000000..f12e4e05 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -0,0 +1,91 @@ +"use client"; + +/** + * 통계 카드 서브타입 컴포넌트 + * + * 상태별 건수 표시 (대기/진행/완료 등) + * 각 카테고리별 색상 및 링크 지원 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface StatCardProps { + item: DashboardItem; + /** 카테고리별 건수 맵 (카테고리 label -> 건수) */ + categoryData: Record; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_STAT_COLORS = [ + "#6366f1", // indigo + "#f59e0b", // amber + "#10b981", // emerald + "#ef4444", // rose + "#8b5cf6", // violet +]; + +// ===== 메인 컴포넌트 ===== + +export function StatCardComponent({ item, categoryData }: StatCardProps) { + const { visibility, statConfig } = item; + const categories = statConfig?.categories ?? []; + const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); + + return ( +
+ {/* 라벨 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 총합 */} + {visibility.showValue && ( +

+ {abbreviateNumber(total)} +

+ )} + + {/* 카테고리별 건수 */} +
+ {categories.map((cat, index) => { + const count = categoryData[cat.label] ?? 0; + const color = + cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length]; + + return ( +
+ {/* 색상 점 */} + + {/* 라벨 + 건수 */} + + {cat.label} + + + {abbreviateNumber(count)} + +
+ ); + })} +
+ + {/* 보조 라벨 (단위 등) */} + {visibility.showSubLabel && ( +

+ {visibility.showUnit && item.kpiConfig?.unit + ? `단위: ${item.kpiConfig.unit}` + : ""} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx new file mode 100644 index 00000000..51a05814 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * 좌우 버튼 표시 모드 + * + * 화살표 버튼으로 아이템을 한 장씩 넘기는 모드 + * 터치 최적화: 최소 44x44px 터치 영역 + */ + +import React, { useState, useCallback } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +// ===== Props ===== + +export interface ArrowsModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ArrowsModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ArrowsModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrev = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + }, [itemCount]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + }, [itemCount]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 콘텐츠 + 화살표 */} +
+ {/* 왼쪽 화살표 */} + {itemCount > 1 && ( + + )} + + {/* 아이템 */} +
+ {renderItem(currentIndex)} +
+ + {/* 오른쪽 화살표 */} + {itemCount > 1 && ( + + )} +
+ + {/* 페이지 인디케이터 */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx new file mode 100644 index 00000000..a984bbf6 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx @@ -0,0 +1,141 @@ +"use client"; + +/** + * 자동 슬라이드 표시 모드 + * + * 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개 + * 컴포넌트 unmount 시 타이머 정리 필수 + */ + +import React, { useState, useEffect, useRef, useCallback } from "react"; + +// ===== Props ===== + +export interface AutoSlideModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 자동 전환 간격 (초, 기본 5) */ + interval?: number; + /** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */ + resumeDelay?: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function AutoSlideModeComponent({ + itemCount, + interval = 5, + resumeDelay = 3, + showIndicator = true, + renderItem, +}: AutoSlideModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const intervalRef = useRef | null>(null); + const resumeTimerRef = useRef | null>(null); + + // 타이머 정리 함수 + const clearTimers = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (resumeTimerRef.current) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + }, []); + + // 자동 슬라이드 시작 + const startAutoSlide = useCallback(() => { + clearTimers(); + if (itemCount <= 1) return; + + intervalRef.current = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % itemCount); + }, interval * 1000); + }, [itemCount, interval, clearTimers]); + + // 터치/클릭으로 일시 정지 + const handlePause = useCallback(() => { + setIsPaused(true); + clearTimers(); + + // resumeDelay 후 자동 재개 + resumeTimerRef.current = setTimeout(() => { + setIsPaused(false); + startAutoSlide(); + }, resumeDelay * 1000); + }, [resumeDelay, clearTimers, startAutoSlide]); + + // 마운트 시 자동 슬라이드 시작, unmount 시 정리 + useEffect(() => { + if (!isPaused) { + startAutoSlide(); + } + return clearTimers; + }, [isPaused, startAutoSlide, clearTimers]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 콘텐츠 (슬라이드 애니메이션) */} +
+
+
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+
+
+ + {/* 인디케이터 + 일시정지 표시 */} + {showIndicator && itemCount > 1 && ( +
+ {isPaused && ( + 일시정지 + )} + {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx new file mode 100644 index 00000000..36a75934 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * 그리드 표시 모드 + * + * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) + * 각 셀에 @container 적용하여 내부 아이템 반응형 + */ + +import React from "react"; +import type { DashboardCell } from "../../types"; + +// ===== Props ===== + +export interface GridModeProps { + /** 셀 배치 정보 */ + cells: DashboardCell[]; + /** 열 수 */ + columns: number; + /** 행 수 */ + rows: number; + /** 아이템 간 간격 (px) */ + gap?: number; + /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ + renderItem: (itemId: string) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function GridModeComponent({ + cells, + columns, + rows, + gap = 8, + renderItem, +}: GridModeProps) { + if (!cells.length) { + return ( +
+ 셀 없음 +
+ ); + } + + return ( +
+ {cells.map((cell) => ( +
+ {cell.itemId ? ( + renderItem(cell.itemId) + ) : ( +
+ 빈 셀 +
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx new file mode 100644 index 00000000..300b637d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx @@ -0,0 +1,90 @@ +"use client"; + +/** + * 스크롤 표시 모드 + * + * 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅 + * 터치 스와이프 네이티브 지원 + */ + +import React, { useRef, useState, useEffect, useCallback } from "react"; + +// ===== Props ===== + +export interface ScrollModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ScrollModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ScrollModeProps) { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + // 스크롤 위치로 현재 인덱스 계산 + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || !el.clientWidth) return; + const index = Math.round(el.scrollLeft / el.clientWidth); + setActiveIndex(Math.min(index, itemCount - 1)); + }, [itemCount]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 스크롤 영역 */} +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+ + {/* 페이지 인디케이터 */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts new file mode 100644 index 00000000..74dfcd4c --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -0,0 +1,235 @@ +/** + * pop-dashboard 데이터 페처 + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정 + * 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체. + * + * 보안: + * - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리 + * - 멀티테넌시: autoFilter 자동 전달 + * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 + */ + +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import type { DataSourceConfig, DataSourceFilter } from "../../types"; + +// ===== 반환 타입 ===== + +export interface AggregatedResult { + value: number; + rows?: Record[]; + error?: string; +} + +export interface ColumnInfo { + name: string; + type: string; + udtName: string; +} + +// ===== SQL 값 이스케이프 ===== + +/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ +function escapeSQL(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + // 문자열: 작은따옴표 이스케이프 + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +// ===== 필터 조건 SQL 생성 ===== + +/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ +function buildWhereClause(filters: DataSourceFilter[]): string { + if (!filters.length) return ""; + + const conditions = filters.map((f) => { + const col = sanitizeIdentifier(f.column); + + switch (f.operator) { + case "between": { + const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; + return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; + } + case "in": { + const arr = Array.isArray(f.value) ? f.value : [f.value]; + const vals = arr.map(escapeSQL).join(", "); + return `${col} IN (${vals})`; + } + case "like": + return `${col} LIKE ${escapeSQL(f.value)}`; + default: + return `${col} ${f.operator} ${escapeSQL(f.value)}`; + } + }); + + return `WHERE ${conditions.join(" AND ")}`; +} + +// ===== 식별자 검증 (테이블명, 컬럼명) ===== + +/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 집계 SQL 빌더 ===== + +/** + * DataSourceConfig를 SELECT SQL로 변환 + * + * @param config - 데이터 소스 설정 + * @returns SQL 문자열 + */ +export function buildAggregationSQL(config: DataSourceConfig): string { + const tableName = sanitizeIdentifier(config.tableName); + + // SELECT 절 + let selectClause: string; + if (config.aggregation) { + const aggType = config.aggregation.type.toUpperCase(); + const aggCol = sanitizeIdentifier(config.aggregation.column); + selectClause = `${aggType}(${aggCol}) as value`; + + // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 + if (config.aggregation.groupBy?.length) { + const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); + selectClause = `${groupCols}, ${selectClause}`; + } + } else { + selectClause = "*"; + } + + // FROM 절 (조인 포함) + let fromClause = tableName; + if (config.joins?.length) { + for (const join of config.joins) { + const joinTable = sanitizeIdentifier(join.targetTable); + const joinType = join.joinType.toUpperCase(); + const srcCol = sanitizeIdentifier(join.on.sourceColumn); + const tgtCol = sanitizeIdentifier(join.on.targetColumn); + fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; + } + } + + // WHERE 절 + const whereClause = config.filters?.length + ? buildWhereClause(config.filters) + : ""; + + // GROUP BY 절 + let groupByClause = ""; + if (config.aggregation?.groupBy?.length) { + groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; + } + + // ORDER BY 절 + let orderByClause = ""; + if (config.sort?.length) { + const sortCols = config.sort + .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) + .join(", "); + orderByClause = `ORDER BY ${sortCols}`; + } + + // LIMIT 절 + const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; + + return [ + `SELECT ${selectClause}`, + `FROM ${fromClause}`, + whereClause, + groupByClause, + orderByClause, + limitClause, + ] + .filter(Boolean) + .join(" "); +} + +// ===== 메인 데이터 페처 ===== + +/** + * DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환 + * + * API 선택 전략: + * 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery() + * 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원) + * 3. 단순 조회 -> dataApi.getTableData() + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체 + */ +export async function fetchAggregatedData( + config: DataSourceConfig +): Promise { + try { + // 집계 또는 조인이 있으면 SQL 직접 실행 + if (config.aggregation || (config.joins && config.joins.length > 0)) { + const sql = buildAggregationSQL(config); + const result = await dashboardApi.executeQuery(sql); + + if (result.rows.length === 0) { + return { value: 0, rows: [] }; + } + + // 첫 번째 행의 value 컬럼 추출 + const firstRow = result.rows[0]; + const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0); + + return { + value: Number.isFinite(numericValue) ? numericValue : 0, + rows: result.rows, + }; + } + + // 단순 조회 + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: config.filters?.reduce( + (acc, f) => { + acc[f.column] = f.value; + return acc; + }, + {} as Record + ), + }); + + // 단순 조회 시에는 행 수를 value로 사용 + return { + value: tableResult.total ?? tableResult.data.length, + rows: tableResult.data, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + return { value: 0, error: message }; + } +} + +// ===== 설정 패널용 헬퍼 ===== + +/** + * 테이블 목록 조회 (설정 패널 드롭다운용) + * dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되, + * 테이블 목록은 별도로 필요하므로 간단히 반환 + */ +export async function fetchTableColumns( + tableName: string +): Promise { + try { + const schema = await dashboardApi.getTableSchema(tableName); + return schema.columns.map((col) => ({ + name: col.name, + type: col.type, + udtName: col.udtName, + })); + } catch { + return []; + } +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts new file mode 100644 index 00000000..2ed27a98 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts @@ -0,0 +1,259 @@ +/** + * pop-dashboard 수식 파싱 및 평가 유틸리티 + * + * 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현. + */ + +import type { FormulaConfig, FormulaDisplayFormat } from "../../types"; + +// ===== 토큰 타입 ===== + +type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen"; + +interface Token { + type: TokenType; + value: string; +} + +// ===== 토크나이저 ===== + +/** 수식 문자열을 토큰 배열로 분리 */ +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; + let i = 0; + const expr = expression.replace(/\s+/g, ""); + + while (i < expr.length) { + const ch = expr[i]; + + // 숫자 (정수, 소수) + if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) { + let num = ""; + while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) { + num += expr[i]; + i++; + } + tokens.push({ type: "number", value: num }); + continue; + } + + // 변수 (A, B, C 등 알파벳) + if (/[A-Za-z]/.test(ch)) { + let varName = ""; + while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) { + varName += expr[i]; + i++; + } + tokens.push({ type: "variable", value: varName }); + continue; + } + + // 연산자 + if ("+-*/".includes(ch)) { + tokens.push({ type: "operator", value: ch }); + i++; + continue; + } + + // 괄호 + if (ch === "(") { + tokens.push({ type: "lparen", value: "(" }); + i++; + continue; + } + if (ch === ")") { + tokens.push({ type: "rparen", value: ")" }); + i++; + continue; + } + + // 알 수 없는 문자는 건너뜀 + i++; + } + + return tokens; +} + +// ===== 재귀 하강 파서 ===== + +/** + * 사칙연산 수식을 안전하게 평가 (재귀 하강 파서) + * + * 문법: + * expr = term (('+' | '-') term)* + * term = factor (('*' | '/') factor)* + * factor = NUMBER | VARIABLE | '(' expr ')' + * + * @param expression - 수식 문자열 (예: "A / B * 100") + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 계산 결과 (0으로 나누기 시 0 반환) + */ +export function evaluateFormula( + expression: string, + values: Record +): number { + const tokens = tokenize(expression); + let pos = 0; + + function peek(): Token | undefined { + return tokens[pos]; + } + + function consume(): Token { + return tokens[pos++]; + } + + // factor = NUMBER | VARIABLE | '(' expr ')' + function parseFactor(): number { + const token = peek(); + if (!token) return 0; + + if (token.type === "number") { + consume(); + return parseFloat(token.value); + } + + if (token.type === "variable") { + consume(); + return values[token.value] ?? 0; + } + + if (token.type === "lparen") { + consume(); // '(' 소비 + const result = parseExpr(); + if (peek()?.type === "rparen") { + consume(); // ')' 소비 + } + return result; + } + + // 예상치 못한 토큰 + consume(); + return 0; + } + + // term = factor (('*' | '/') factor)* + function parseTerm(): number { + let result = parseFactor(); + while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) { + const op = consume().value; + const right = parseFactor(); + if (op === "*") { + result *= right; + } else { + // 0으로 나누기 방지 + result = right === 0 ? 0 : result / right; + } + } + return result; + } + + // expr = term (('+' | '-') term)* + function parseExpr(): number { + let result = parseTerm(); + while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) { + const op = consume().value; + const right = parseTerm(); + result = op === "+" ? result + right : result - right; + } + return result; + } + + const result = parseExpr(); + return Number.isFinite(result) ? result : 0; +} + +/** + * 수식 결과를 displayFormat에 맞게 포맷팅 + * + * @param config - 수식 설정 + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 포맷된 문자열 + */ +export function formatFormulaResult( + config: FormulaConfig, + values: Record +): string { + const formatMap: Record string> = { + value: () => { + const result = evaluateFormula(config.expression, values); + return formatNumber(result); + }, + fraction: () => { + // "1,234 / 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + percent: () => { + const result = evaluateFormula(config.expression, values); + return `${(result * 100).toFixed(1)}%`; + }, + ratio: () => { + // "1,234 : 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + }; + + return formatMap[config.displayFormat](); +} + +/** + * 수식에 사용된 변수 ID가 모두 존재하는지 검증 + * + * @param expression - 수식 문자열 + * @param availableIds - 사용 가능한 변수 ID 배열 + * @returns 유효 여부 + */ +export function validateExpression( + expression: string, + availableIds: string[] +): boolean { + const tokens = tokenize(expression); + const usedVars = tokens + .filter((t) => t.type === "variable") + .map((t) => t.value); + + return usedVars.every((v) => availableIds.includes(v)); +} + +/** + * 큰 숫자 축약 (Container Query 축소 시 사용) + * + * 1234 -> "1,234" + * 12345 -> "1.2만" + * 1234567 -> "123.5만" + * 123456789 -> "1.2억" + */ +export function abbreviateNumber(value: number): string { + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + + if (abs >= 100_000_000) { + return `${sign}${(abs / 100_000_000).toFixed(1)}억`; + } + if (abs >= 10_000) { + return `${sign}${(abs / 10_000).toFixed(1)}만`; + } + return `${sign}${formatNumber(abs)}`; +} + +// ===== 내부 헬퍼 ===== + +/** 숫자를 천 단위 콤마 포맷 */ +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString("ko-KR"); + } + // 소수점 이하 최대 2자리 + return value.toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} diff --git a/frontend/lib/registry/pop-components/pop-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx index 8cad19ad..ab8b9e92 100644 --- a/frontend/lib/registry/pop-components/pop-text.tsx +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -269,11 +269,14 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) { function DateTimeDisplay({ config }: { config?: PopTextConfig }) { const [now, setNow] = useState(new Date()); + // isRealtime 기본값: true (설정 패널 UI와 일치) + const isRealtime = config?.isRealtime ?? true; + useEffect(() => { - if (!config?.isRealtime) return; + if (!isRealtime) return; const timer = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(timer); - }, [config?.isRealtime]); + }, [isRealtime]); // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) const dateFormat = config?.dateTimeConfig diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index f743d766..4e8ae079 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -86,3 +86,237 @@ export const JUSTIFY_CLASSES: Record = { center: "justify-center", right: "justify-end", }; + +// ============================================= +// Phase 0 공통 타입 (모든 POP 컴포넌트 공용) +// ============================================= + +// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 ----- + +export type ColumnMode = "read" | "write" | "readwrite" | "hidden"; + +export interface ColumnBinding { + columnName: string; + sourceTable?: string; + mode: ColumnMode; + label?: string; + defaultValue?: unknown; +} + +// ----- 조인 설정: 테이블 간 관계 정의 ----- + +export type JoinType = "inner" | "left" | "right"; + +export interface JoinConfig { + targetTable: string; + joinType: JoinType; + on: { + sourceColumn: string; + targetColumn: string; + }; + columns?: string[]; +} + +// ----- 데이터 소스: 테이블 조회/집계 통합 설정 ----- + +export type AggregationType = "count" | "sum" | "avg" | "min" | "max"; +export type FilterOperator = + | "=" + | "!=" + | ">" + | ">=" + | "<" + | "<=" + | "like" + | "in" + | "between"; + +export interface DataSourceFilter { + column: string; + operator: FilterOperator; + value: unknown; // between이면 [from, to] +} + +export interface SortConfig { + column: string; + direction: "asc" | "desc"; +} + +export interface DataSourceConfig { + tableName: string; + columns?: ColumnBinding[]; + filters?: DataSourceFilter[]; + sort?: SortConfig[]; + aggregation?: { + type: AggregationType; + column: string; + groupBy?: string[]; + }; + joins?: JoinConfig[]; + refreshInterval?: number; // 초 단위, 0이면 비활성 + limit?: number; +} + +// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 ----- + +export interface PopActionConfig { + type: + | "navigate" + | "modal" + | "save" + | "delete" + | "api" + | "event" + | "refresh"; + // navigate + targetScreenId?: string; + params?: Record; + // modal + modalScreenId?: string; + modalTitle?: string; + // save/delete + targetTable?: string; + confirmMessage?: string; + // api + apiEndpoint?: string; + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; + // event + eventName?: string; + eventPayload?: Record; +} + +// ============================================= +// pop-dashboard 전용 타입 +// ============================================= + +// ----- 표시 모드 / 서브타입 ----- + +export type DashboardDisplayMode = + | "arrows" + | "auto-slide" + | "grid" + | "scroll"; +export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card"; +export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio"; +export type ChartType = "bar" | "pie" | "line"; +export type TrendPeriod = "daily" | "weekly" | "monthly"; + +// ----- 색상 구간 ----- + +export interface ColorRange { + min: number; + max: number; + color: string; // hex 또는 Tailwind 색상 +} + +// ----- 수식(계산식) 설정 ----- + +export interface FormulaValue { + id: string; // "A", "B" 등 + dataSource: DataSourceConfig; + label: string; // "생산량", "총재고량" +} + +export interface FormulaConfig { + enabled: boolean; + values: FormulaValue[]; + expression: string; // "A / B", "A + B", "A / B * 100" + displayFormat: FormulaDisplayFormat; +} + +// ----- 아이템 내 요소별 보이기/숨기기 ----- + +export interface ItemVisibility { + showLabel: boolean; + showValue: boolean; + showUnit: boolean; + showTrend: boolean; + showSubLabel: boolean; + showTarget: boolean; +} + +// ----- 서브타입별 설정 ----- + +export interface KpiCardConfig { + unit?: string; // "EA", "톤", "원" + colorRanges?: ColorRange[]; + showTrend?: boolean; + trendPeriod?: TrendPeriod; +} + +export interface ChartItemConfig { + chartType: ChartType; + xAxisColumn?: string; + yAxisColumn?: string; + colors?: string[]; +} + +export interface GaugeConfig { + min: number; + max: number; + target?: number; // 고정 목표값 + targetDataSource?: DataSourceConfig; // 동적 목표값 + colorRanges?: ColorRange[]; +} + +export interface StatCategory { + label: string; // "대기", "진행", "완료" + filter: DataSourceFilter; + color?: string; +} + +export interface StatCardConfig { + categories: StatCategory[]; + showLink?: boolean; + linkAction?: PopActionConfig; +} + +// ----- 그리드 모드 셀 (엑셀형 분할/병합) ----- + +export interface DashboardCell { + id: string; + gridColumn: string; // CSS Grid 값: "1 / 3" + gridRow: string; // CSS Grid 값: "1 / 2" + itemId: string | null; // null이면 빈 셀 +} + +// ----- 대시보드 아이템 ----- + +export interface DashboardItem { + id: string; + label: string; // pop-system 보이기/숨기기용 + visible: boolean; + subType: DashboardSubType; + dataSource: DataSourceConfig; + + // 요소별 보이기/숨기기 + visibility: ItemVisibility; + + // 계산식 (선택사항) + formula?: FormulaConfig; + + // 서브타입별 설정 (subType에 따라 하나만 사용) + kpiConfig?: KpiCardConfig; + chartConfig?: ChartItemConfig; + gaugeConfig?: GaugeConfig; + statConfig?: StatCardConfig; +} + +// ----- 대시보드 전체 설정 ----- + +export interface PopDashboardConfig { + items: DashboardItem[]; + displayMode: DashboardDisplayMode; + + // 모드별 설정 + autoSlideInterval?: number; // 초 (기본 5) + autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3) + gridCells?: DashboardCell[]; // grid 모드 셀 배치 + gridColumns?: number; // grid 모드 열 수 (기본 2) + gridRows?: number; // grid 모드 행 수 (기본 2) + + // 공통 스타일 + showIndicator?: boolean; // 페이지 인디케이터 + gap?: number; // 아이템 간 간격 px + backgroundColor?: string; +} From 73e3d5638136d591aad492301182ecee05c9d033 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 12:20:44 +0900 Subject: [PATCH 05/55] =?UTF-8?q?fix(pop-dashboard):=20React=20Hooks=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=9C=84=EB=B0=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?+=20ConfigPanel=20props=20=EC=A0=95=ED=95=A9=EC=84=B1=20+=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PopDashboardComponent: early return을 모든 hooks 이후로 이동 (Rules of Hooks) - PopDashboardConfigPanel: onChange -> onUpdate prop 이름 정합, 빈 객체 config 방어 - PopDashboardPreview: Array.isArray 방어 추가 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardComponent.tsx | 30 ++++++++++--------- .../pop-dashboard/PopDashboardConfig.tsx | 7 +++-- .../pop-dashboard/PopDashboardPreview.tsx | 3 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 53940b0a..7c6e7c50 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -111,19 +111,10 @@ export function PopDashboardComponent({ const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(300); - // 빈 설정 - if (!config || !config.items.length) { - return ( -
- - 대시보드 아이템을 추가하세요 - -
- ); - } - - // 보이는 아이템만 필터링 - const visibleItems = config.items.filter((item) => item.visible); + // 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용) + const visibleItems = Array.isArray(config?.items) + ? config.items.filter((item) => item.visible) + : []; // 컨테이너 크기 감지 useEffect(() => { @@ -140,6 +131,7 @@ export function PopDashboardComponent({ }, []); // 데이터 로딩 함수 + // eslint-disable-next-line react-hooks/exhaustive-deps const fetchAllData = useCallback(async () => { if (!visibleItems.length) { setLoading(false); @@ -165,7 +157,6 @@ export function PopDashboardComponent({ setDataMap(newDataMap); setLoading(false); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(visibleItems.map((i) => i.id))]); // 초기 로딩 + 주기적 새로고침 @@ -186,6 +177,17 @@ export function PopDashboardComponent({ }; }, [fetchAllData, visibleItems]); + // 빈 설정 (모든 hooks 이후에 early return) + if (!config || !config.items?.length) { + return ( +
+ + 대시보드 아이템을 추가하세요 + +
+ ); + } + // 단일 아이템 렌더링 const renderSingleItem = (item: DashboardItem) => { const itemData = dataMap[item.id]; diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 74126c22..26da10f5 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -45,7 +45,7 @@ import { validateExpression } from "./utils/formula"; interface ConfigPanelProps { config: PopDashboardConfig | undefined; - onChange: (config: PopDashboardConfig) => void; + onUpdate: (config: PopDashboardConfig) => void; } // ===== 기본값 ===== @@ -806,9 +806,10 @@ function GridLayoutEditor({ export function PopDashboardConfigPanel({ config, - onChange, + onUpdate: onChange, }: ConfigPanelProps) { - const cfg = config ?? DEFAULT_CONFIG; + // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 + const cfg: PopDashboardConfig = { ...DEFAULT_CONFIG, ...(config || {}) }; const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( "basic" ); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx index db64f04c..8d530b96 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -64,7 +64,8 @@ export function PopDashboardPreviewComponent({ }: { config?: PopDashboardConfig; }) { - if (!config || !config.items.length) { + // config가 빈 객체 {} 또는 items가 없는 경우 방어 + if (!config || !Array.isArray(config.items) || !config.items.length) { return (
From dc523d86c3f2e484e4f6a21cb6fbc625e5371179 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 14:22:30 +0900 Subject: [PATCH 06/55] =?UTF-8?q?feat(pop-dashboard):=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조 변경: - grid 모드를 독립 displayMode에서 페이지 내부 그리드 레이아웃으로 전환 - DashboardPage 타입 추가 (각 페이지가 독립 그리드 보유) - migrateConfig()로 기존 grid/useGridLayout 설정 자동 마이그레이션 설정 패널 (PopDashboardConfig): - 드롭다운 기반 집계 설정 UI 전면 재작성 (+917줄) - 테이블/컬럼 선택 Combobox, 페이지 관리, 셀 배치 편집기 - fetchTableList() 추가 (테이블 목록 조회) 컴포넌트/모드 개선: - GridMode: 반응형 자동 열 축소 (MIN_CELL_WIDTH 기준) - PopDashboardComponent: 페이지 기반 렌더링 로직 - PopDashboardPreview: 페이지 뱃지 표시 기타: - ComponentEditorPanel: 탭 콘텐츠 스크롤 수정 (min-h-0 추가) - types.ts: grid를 displayMode에서 제거, DashboardPage 타입 추가 Co-authored-by: Cursor --- .../designer/panels/ComponentEditorPanel.tsx | 12 +- .../pop-dashboard/PopDashboardComponent.tsx | 121 +- .../pop-dashboard/PopDashboardConfig.tsx | 1088 ++++++++++++++--- .../pop-dashboard/PopDashboardPreview.tsx | 37 +- .../pop-components/pop-dashboard/index.tsx | 1 + .../pop-dashboard/modes/GridMode.tsx | 118 +- .../pop-dashboard/utils/dataFetcher.ts | 24 + frontend/lib/registry/pop-components/types.ts | 21 +- 8 files changed, 1197 insertions(+), 225 deletions(-) diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ddb7ac79..d58eff84 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -97,8 +97,8 @@ export default function ComponentEditorPanel({
{/* 탭 */} - - + + 위치 @@ -118,7 +118,7 @@ export default function ComponentEditorPanel({ {/* 위치 탭 */} - + {/* 설정 탭 */} - + {/* 표시 탭 */} - + {/* 데이터 탭 */} - + diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 7c6e7c50..0f1aba1a 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -14,6 +14,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import type { PopDashboardConfig, DashboardItem, + DashboardPage, } from "../types"; import { fetchAggregatedData } from "./utils/dataFetcher"; import { @@ -33,6 +34,62 @@ import { AutoSlideModeComponent } from "./modes/AutoSlideMode"; import { GridModeComponent } from "./modes/GridMode"; import { ScrollModeComponent } from "./modes/ScrollMode"; +// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 ===== + +/** + * 기존 config를 페이지 기반 구조로 마이그레이션. + * 런타임에서만 사용 (저장된 config 원본은 변경하지 않음). + * + * 시나리오1: displayMode="grid" (가장 오래된 형태) + * 시나리오2: useGridLayout=true (직전 마이그레이션 결과) + * 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요 + * 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드) + */ +export function migrateConfig( + raw: Record +): PopDashboardConfig { + const config = { ...raw } as PopDashboardConfig & Record; + + // pages가 이미 있으면 마이그레이션 불필요 + if ( + Array.isArray(config.pages) && + config.pages.length > 0 + ) { + return config; + } + + // 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true + const wasGrid = + config.displayMode === ("grid" as string) || + (config as Record).useGridLayout === true; + + if (wasGrid) { + const cols = + ((config as Record).gridColumns as number) ?? 2; + const rows = + ((config as Record).gridRows as number) ?? 2; + const cells = + ((config as Record).gridCells as DashboardPage["gridCells"]) ?? []; + + const page: DashboardPage = { + id: "migrated-page-1", + label: "페이지 1", + gridColumns: cols, + gridRows: rows, + gridCells: cells, + }; + + config.pages = [page]; + + // displayMode="grid" 보정 + if (config.displayMode === ("grid" as string)) { + (config as Record).displayMode = "arrows"; + } + } + + return config as PopDashboardConfig; +} + // ===== 내부 타입 ===== interface ItemData { @@ -259,9 +316,43 @@ export function PopDashboardComponent({ ); } - // 표시 모드별 렌더링 - const displayMode = config.displayMode; + // 마이그레이션: 기존 config를 페이지 기반으로 변환 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const displayMode = migrated.displayMode; + // 페이지 하나를 GridModeComponent로 렌더링 + const renderPageContent = (page: DashboardPage) => ( + { + const item = visibleItems.find((i) => i.id === itemId); + if (!item) return null; + return renderSingleItem(item); + }} + /> + ); + + // 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작) + const slideCount = pages.length > 0 ? pages.length : visibleItems.length; + + // 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템 + const renderSlide = (index: number) => { + if (pages.length > 0 && pages[index]) { + return renderPageContent(pages[index]); + } + // fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시) + if (visibleItems[index]) { + return renderSingleItem(visibleItems[index]); + } + return null; + }; + + // 표시 모드별 렌더링 return (
{displayMode === "arrows" && ( renderSingleItem(visibleItems[index])} + renderItem={renderSlide} /> )} {displayMode === "auto-slide" && ( renderSingleItem(visibleItems[index])} - /> - )} - - {displayMode === "grid" && ( - { - const item = visibleItems.find((i) => i.id === itemId); - if (!item) return null; - return renderSingleItem(item); - }} + renderItem={renderSlide} /> )} {displayMode === "scroll" && ( renderSingleItem(visibleItems[index])} + renderItem={renderSlide} /> )}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 26da10f5..1b0ec03c 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -6,7 +6,7 @@ * 3개 탭: * [기본 설정] - 표시 모드, 간격, 인디케이터 * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정 - * [레이아웃] - grid 모드 셀 분할/병합 + * [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃 */ import React, { useState, useEffect, useCallback } from "react"; @@ -16,6 +16,8 @@ import { ChevronDown, ChevronUp, GripVertical, + Check, + ChevronsUpDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,17 +30,42 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import type { PopDashboardConfig, DashboardItem, DashboardSubType, DashboardDisplayMode, DataSourceConfig, + DataSourceFilter, + FilterOperator, FormulaConfig, ItemVisibility, DashboardCell, + DashboardPage, + JoinConfig, + JoinType, } from "../types"; -import { fetchTableColumns, type ColumnInfo } from "./utils/dataFetcher"; +import { migrateConfig } from "./PopDashboardComponent"; +import { + fetchTableColumns, + fetchTableList, + type ColumnInfo, + type TableInfo, +} from "./utils/dataFetcher"; import { validateExpression } from "./utils/formula"; // ===== Props ===== @@ -52,14 +79,12 @@ interface ConfigPanelProps { const DEFAULT_CONFIG: PopDashboardConfig = { items: [], + pages: [], displayMode: "arrows", autoSlideInterval: 5, autoSlideResumeDelay: 3, showIndicator: true, gap: 8, - gridColumns: 2, - gridRows: 2, - gridCells: [], }; const DEFAULT_VISIBILITY: ItemVisibility = { @@ -82,7 +107,6 @@ const DEFAULT_DATASOURCE: DataSourceConfig = { const DISPLAY_MODE_LABELS: Record = { arrows: "좌우 버튼", "auto-slide": "자동 슬라이드", - grid: "그리드", scroll: "스크롤", }; @@ -93,6 +117,24 @@ const SUBTYPE_LABELS: Record = { "stat-card": "통계 카드", }; +const JOIN_TYPE_LABELS: Record = { + inner: "INNER JOIN", + left: "LEFT JOIN", + right: "RIGHT JOIN", +}; + +const FILTER_OPERATOR_LABELS: Record = { + "=": "같음 (=)", + "!=": "다름 (!=)", + ">": "초과 (>)", + ">=": "이상 (>=)", + "<": "미만 (<)", + "<=": "이하 (<=)", + like: "포함 (LIKE)", + in: "목록 (IN)", + between: "범위 (BETWEEN)", +}; + // ===== 데이터 소스 편집기 ===== function DataSourceEditor({ @@ -102,9 +144,23 @@ function DataSourceEditor({ dataSource: DataSourceConfig; onChange: (ds: DataSourceConfig) => void; }) { + // 테이블 목록 (Combobox용) + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableOpen, setTableOpen] = useState(false); + + // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); + // 마운트 시 테이블 목록 로드 + useEffect(() => { + setLoadingTables(true); + fetchTableList() + .then(setTables) + .finally(() => setLoadingTables(false)); + }, []); + // 테이블 변경 시 컬럼 목록 조회 useEffect(() => { if (!dataSource.tableName) { @@ -119,20 +175,81 @@ function DataSourceEditor({ return (
- {/* 테이블명 입력 */} + {/* 테이블 선택 (검색 가능한 Combobox) */}
- - - onChange({ ...dataSource, tableName: e.target.value }) - } - placeholder="예: production" - className="h-8 text-xs" - /> + + + + + + + + + + + 테이블을 찾을 수 없습니다 + + + {tables.map((table) => ( + { + const newVal = + table.tableName === dataSource.tableName + ? "" + : table.tableName; + onChange({ ...dataSource, tableName: newVal }); + setTableOpen(false); + }} + className="text-xs" + > + +
+ + {table.displayName || table.tableName} + + {table.displayName && + table.displayName !== table.tableName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
- {/* 집계 함수 */} + {/* 집계 함수 + 대상 컬럼 */}
@@ -143,7 +260,9 @@ function DataSourceEditor({ ...dataSource, aggregation: val ? { - type: val as DataSourceConfig["aggregation"] extends undefined ? never : NonNullable["type"], + type: val as NonNullable< + DataSourceConfig["aggregation"] + >["type"], column: dataSource.aggregation?.column ?? "", } : undefined, @@ -163,7 +282,6 @@ function DataSourceEditor({
- {/* 집계 대상 컬럼 */} {dataSource.aggregation && (
@@ -172,15 +290,14 @@ function DataSourceEditor({ onValueChange={(val) => onChange({ ...dataSource, - aggregation: { - ...dataSource.aggregation!, - column: val, - }, + aggregation: { ...dataSource.aggregation!, column: val }, }) } > - + {columns.map((col) => ( @@ -194,22 +311,469 @@ function DataSourceEditor({ )}
- {/* 새로고침 주기 */} -
- - - onChange({ - ...dataSource, - refreshInterval: parseInt(e.target.value) || 0, - }) - } - className="h-8 text-xs" - min={0} - /> + {/* 자동 새로고침 (Switch + 주기 입력) */} +
+
+ + 0} + onCheckedChange={(checked) => + onChange({ + ...dataSource, + refreshInterval: checked ? 30 : 0, + }) + } + /> +
+ {(dataSource.refreshInterval ?? 0) > 0 && ( +
+ + + onChange({ + ...dataSource, + refreshInterval: Math.max( + 5, + parseInt(e.target.value) || 30 + ), + }) + } + className="h-7 text-xs" + min={5} + /> +
+ )}
+ + {/* 조인 설정 */} + onChange({ ...dataSource, joins })} + /> + + {/* 필터 조건 */} + onChange({ ...dataSource, filters })} + /> +
+ ); +} + +// ===== 조인 편집기 ===== + +function JoinEditor({ + joins, + mainTable, + onChange, +}: { + joins: JoinConfig[]; + mainTable: string; + onChange: (joins: JoinConfig[]) => void; +}) { + const [tables, setTables] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const addJoin = () => { + onChange([ + ...joins, + { + targetTable: "", + joinType: "left", + on: { sourceColumn: "", targetColumn: "" }, + }, + ]); + }; + + const updateJoin = (index: number, partial: Partial) => { + const newJoins = [...joins]; + newJoins[index] = { ...newJoins[index], ...partial }; + onChange(newJoins); + }; + + const removeJoin = (index: number) => { + onChange(joins.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {!mainTable && joins.length === 0 && ( +

+ 먼저 메인 테이블을 선택하세요 +

+ )} + + {joins.map((join, index) => ( + updateJoin(index, partial)} + onRemove={() => removeJoin(index)} + /> + ))} +
+ ); +} + +function JoinRow({ + join, + mainTable, + tables, + onUpdate, + onRemove, +}: { + join: JoinConfig; + mainTable: string; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [targetTableOpen, setTargetTableOpen] = useState(false); + + // 메인 테이블 컬럼 로드 + useEffect(() => { + if (!mainTable) return; + fetchTableColumns(mainTable).then(setSourceColumns); + }, [mainTable]); + + // 조인 대상 테이블 컬럼 로드 + useEffect(() => { + if (!join.targetTable) return; + fetchTableColumns(join.targetTable).then(setTargetColumns); + }, [join.targetTable]); + + return ( +
+
+ {/* 조인 타입 */} + + + {/* 조인 대상 테이블 (Combobox) */} + + + + + + + + + + 없음 + + + {tables + .filter((t) => t.tableName !== mainTable) + .map((t) => ( + { + onUpdate({ targetTable: t.tableName }); + setTargetTableOpen(false); + }} + className="text-xs" + > + {t.displayName || t.tableName} + + ))} + + + + + + + {/* 삭제 */} + +
+ + {/* 조인 조건 (ON 절) */} + {join.targetTable && ( +
+ ON + + = + +
+ )} +
+ ); +} + +// ===== 필터 편집기 ===== + +function FilterEditor({ + filters, + tableName, + onChange, +}: { + filters: DataSourceFilter[]; + tableName: string; + onChange: (filters: DataSourceFilter[]) => void; +}) { + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (!tableName) return; + fetchTableColumns(tableName).then(setColumns); + }, [tableName]); + + const addFilter = () => { + onChange([...filters, { column: "", operator: "=", value: "" }]); + }; + + const updateFilter = ( + index: number, + partial: Partial + ) => { + const newFilters = [...filters]; + newFilters[index] = { ...newFilters[index], ...partial }; + + // operator 변경 시 value 초기화 + if (partial.operator) { + if (partial.operator === "between") { + newFilters[index].value = ["", ""]; + } else if (partial.operator === "in") { + newFilters[index].value = []; + } else if ( + typeof newFilters[index].value !== "string" && + typeof newFilters[index].value !== "number" + ) { + newFilters[index].value = ""; + } + } + + onChange(newFilters); + }; + + const removeFilter = (index: number) => { + onChange(filters.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {filters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 */} + + + {/* 값 입력 (연산자에 따라 다른 UI) */} +
+ {filter.operator === "between" ? ( +
+ { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[0] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="시작" + className="h-7 text-[10px]" + /> + { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[1] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="끝" + className="h-7 text-[10px]" + /> +
+ ) : filter.operator === "in" ? ( + { + const vals = e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + updateFilter(index, { value: vals }); + }} + placeholder="값1, 값2, 값3" + className="h-7 text-[10px]" + /> + ) : ( + + updateFilter(index, { value: e.target.value }) + } + placeholder="값" + className="h-7 text-[10px]" + /> + )} +
+ + {/* 삭제 */} + +
+ ))}
); } @@ -255,7 +819,9 @@ function FormulaEditor({ size="icon" className="h-7 w-7" onClick={() => { - const newValues = formula.values.filter((_, i) => i !== index); + const newValues = formula.values.filter( + (_, i) => i !== index + ); onChange({ ...formula, values: newValues }); }} > @@ -280,7 +846,7 @@ function FormulaEditor({ size="sm" className="h-7 w-full text-xs" onClick={() => { - const nextId = String.fromCharCode(65 + formula.values.length); // A, B, C... + const nextId = String.fromCharCode(65 + formula.values.length); onChange({ ...formula, values: [ @@ -373,15 +939,17 @@ function ItemEditor({
{/* 헤더 */}
- - - {item.label || `아이템 ${index + 1}`} - - + + onUpdate({ ...item, label: e.target.value })} + placeholder={`아이템 ${index + 1}`} + className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1" + /> + {SUBTYPE_LABELS[item.subType]} - {/* 이동 버튼 */} - {/* 보이기/숨기기 */} @@ -410,7 +977,6 @@ function ItemEditor({ className="scale-75" /> - {/* 접기/펼치기 */}
- {/* 상세 설정 (접힘) */} + {/* 상세 설정 */} {expanded && (
- {/* 라벨 */} -
- - onUpdate({ ...item, label: e.target.value })} - className="h-8 text-xs" - placeholder="아이템 이름" - /> -
- - {/* 서브타입 */}
- {/* 데이터 소스 / 수식 편집 */} {dataMode === "formula" && item.formula ? ( void; + onChange: (cells: DashboardCell[], cols: number, rows: number) => void; }) { - // 셀이 없으면 기본 그리드 생성 const ensuredCells = - cells.length > 0 - ? cells - : Array.from({ length: gridColumns * gridRows }, (_, i) => ({ - id: `cell-${i}`, - gridColumn: `${(i % gridColumns) + 1} / ${(i % gridColumns) + 2}`, - gridRow: `${Math.floor(i / gridColumns) + 1} / ${Math.floor(i / gridColumns) + 2}`, - itemId: null as string | null, - })); + cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); return (
- {/* 열/행 수 */} -
-
- - { - const newCols = Math.max(1, parseInt(e.target.value) || 1); - onChange(ensuredCells, newCols, gridRows); + {/* 행/열 조절 버튼 */} +
+
+ +
-
- - { - const newRows = Math.max(1, parseInt(e.target.value) || 1); - onChange(ensuredCells, gridColumns, newRows); + disabled={gridColumns <= 1} + > + - + + + {gridColumns} + +
+ +
+ + + + {gridRows} + + +
+ +
- {/* 셀 미리보기 + 아이템 배정 */} + {/* 시각적 그리드 프리뷰 + 아이템 배정 */}
{ensuredCells.map((cell) => (
- + @@ -776,28 +1425,96 @@ function GridLayoutEditor({ ))}
- {/* 셀 재생성 */} -
+ ); +} + +// ===== 페이지 편집기 ===== + +function PageEditor({ + page, + pageIndex, + items, + onChange, + onDelete, +}: { + page: DashboardPage; + pageIndex: number; + items: DashboardItem[]; + onChange: (updatedPage: DashboardPage) => void; + onDelete: () => void; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ {/* 헤더 */} +
+ + {page.label || `페이지 ${pageIndex + 1}`} + + + {page.gridColumns}x{page.gridRows} + + + +
+ + {/* 상세 */} + {expanded && ( +
+ {/* 라벨 */} +
+ + + onChange({ ...page, label: e.target.value }) + } + placeholder={`페이지 ${pageIndex + 1}`} + className="h-7 text-xs" + /> +
+ + {/* GridLayoutEditor 재사용 */} + + onChange({ + ...page, + gridCells: cells, + gridColumns: cols, + gridRows: rows, + }) } - } - onChange(newCells, gridColumns, gridRows); - }} - > - 셀 초기화 - + /> +
+ )}
); } @@ -809,8 +1526,14 @@ export function PopDashboardConfigPanel({ onUpdate: onChange, }: ConfigPanelProps) { // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 - const cfg: PopDashboardConfig = { ...DEFAULT_CONFIG, ...(config || {}) }; - const [activeTab, setActiveTab] = useState<"basic" | "items" | "layout">( + const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; + + // 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환 + const cfg = migrateConfig( + merged as unknown as Record + ) as PopDashboardConfig; + + const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">( "basic" ); @@ -848,20 +1571,22 @@ export function PopDashboardConfigPanel({ [cfg.items, updateConfig] ); - // 아이템 삭제 (grid 셀 배정도 해제) + // 아이템 삭제 (모든 페이지의 셀 배정도 해제) const deleteItem = useCallback( (index: number) => { const deletedId = cfg.items[index].id; const newItems = cfg.items.filter((_, i) => i !== index); - // grid 셀에서 해당 아이템 배정 해제 - const newCells = cfg.gridCells?.map((cell) => - cell.itemId === deletedId ? { ...cell, itemId: null } : cell - ); + const newPages = cfg.pages?.map((page) => ({ + ...page, + gridCells: page.gridCells.map((cell) => + cell.itemId === deletedId ? { ...cell, itemId: null } : cell + ), + })); - updateConfig({ items: newItems, gridCells: newCells }); + updateConfig({ items: newItems, pages: newPages }); }, - [cfg.items, cfg.gridCells, updateConfig] + [cfg.items, cfg.pages, updateConfig] ); // 아이템 순서 변경 @@ -884,7 +1609,7 @@ export function PopDashboardConfigPanel({ [ ["basic", "기본 설정"], ["items", "아이템"], - ["layout", "레이아웃"], + ["pages", "페이지"], ] as const ).map(([key, label]) => ( + + {(cfg.pages?.length ?? 0) === 0 && ( +

+ 페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을 + 설정할 수 있습니다. +
+ 페이지가 없으면 아이템이 하나씩 슬라이드됩니다. +

)}
)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx index 8d530b96..2c8b7643 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -10,6 +10,7 @@ import React from "react"; import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; import type { PopDashboardConfig, DashboardSubType } from "../types"; +import { migrateConfig } from "./PopDashboardComponent"; // ===== 서브타입별 아이콘 매핑 ===== @@ -32,7 +33,6 @@ const SUBTYPE_LABELS: Record = { const MODE_LABELS: Record = { arrows: "좌우 버튼", "auto-slide": "자동 슬라이드", - grid: "그리드", scroll: "스크롤", }; @@ -75,34 +75,43 @@ export function PopDashboardPreviewComponent({ } const visibleItems = config.items.filter((i) => i.visible); - const mode = config.displayMode; + + // 마이그레이션 적용 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const hasPages = pages.length > 0; return (
- {/* 모드 표시 */} + {/* 모드 + 페이지 뱃지 */}
- {MODE_LABELS[mode] ?? mode} + {MODE_LABELS[migrated.displayMode] ?? migrated.displayMode} + {hasPages && ( + + {pages.length}페이지 + + )} {visibleItems.length}개
- {/* 모드별 미리보기 */} + {/* 미리보기 */}
- {mode === "grid" ? ( - // 그리드: 셀 구조 시각화 + {hasPages ? ( + // 첫 번째 페이지 그리드 미리보기
- {config.gridCells?.length - ? config.gridCells.map((cell) => { + {pages[0].gridCells.length > 0 + ? pages[0].gridCells.map((cell) => { const item = visibleItems.find( (i) => i.id === cell.itemId ); @@ -125,8 +134,7 @@ export function PopDashboardPreviewComponent({
); }) - : // 셀 미설정: 아이템만 나열 - visibleItems.slice(0, 4).map((item) => ( + : visibleItems.slice(0, 4).map((item) => ( ) : ( - // 다른 모드: 첫 번째 아이템만 크게 표시 + // 페이지 미설정: 첫 번째 아이템만 크게 표시
{visibleItems[0] && ( )} - {/* 추가 아이템 수 뱃지 */} {visibleItems.length > 1 && (
+{visibleItems.length - 1} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx index 01a653b6..58cdf6e2 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx @@ -23,6 +23,7 @@ PopComponentRegistry.registerComponent({ preview: PopDashboardPreviewComponent, defaultProps: { items: [], + pages: [], displayMode: "arrows", autoSlideInterval: 5, autoSlideResumeDelay: 3, diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx index 36a75934..66c4f5e9 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -5,26 +5,110 @@ * * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) * 각 셀에 @container 적용하여 내부 아이템 반응형 + * + * 반응형 자동 조정: + * - containerWidth에 따라 열 수를 자동 축소 + * - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦 + * - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단 */ -import React from "react"; +import React, { useMemo } from "react"; import type { DashboardCell } from "../../types"; +// ===== 상수 ===== + +/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ +const MIN_CELL_WIDTH = 160; + // ===== Props ===== export interface GridModeProps { /** 셀 배치 정보 */ cells: DashboardCell[]; - /** 열 수 */ + /** 설정된 열 수 (최대값) */ columns: number; - /** 행 수 */ + /** 설정된 행 수 */ rows: number; /** 아이템 간 간격 (px) */ gap?: number; + /** 컨테이너 너비 (px, 반응형 자동 조정용) */ + containerWidth?: number; /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ renderItem: (itemId: string) => React.ReactNode; } +// ===== 반응형 열 수 계산 ===== + +/** + * 컨테이너 너비에 맞는 실제 열 수를 계산 + * + * 설정된 columns가 최대값이고, 공간이 부족하면 축소. + * gap도 고려하여 계산. + * + * 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160 + * 사용 가능 너비 = 400 - (3-1)*8 = 384 + * 셀당 너비 = 384/3 = 128 < 160 -> 열 축소 + * columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK + */ +function computeResponsiveColumns( + configColumns: number, + containerWidth: number, + gap: number +): number { + if (containerWidth <= 0) return configColumns; + + for (let cols = configColumns; cols >= 1; cols--) { + const totalGap = (cols - 1) * gap; + const cellWidth = (containerWidth - totalGap) / cols; + if (cellWidth >= MIN_CELL_WIDTH) return cols; + } + + return 1; +} + +/** + * 열 수가 줄어들 때 셀 배치를 자동 재배열 + * + * 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑 + * 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동 + */ +function remapCells( + cells: DashboardCell[], + configColumns: number, + actualColumns: number, + configRows: number +): { remappedCells: DashboardCell[]; actualRows: number } { + // 열 수가 같으면 원본 그대로 + if (actualColumns >= configColumns) { + return { remappedCells: cells, actualRows: configRows }; + } + + // 셀을 원래 위치 순서대로 정렬 (행 우선) + const sorted = [...cells].sort((a, b) => { + const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0; + const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0; + if (aRow !== bRow) return aRow - bRow; + const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0; + const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0; + return aCol - bCol; + }); + + // 순서대로 새 위치에 배치 + let maxRow = 0; + const remapped = sorted.map((cell, index) => { + const newCol = (index % actualColumns) + 1; + const newRow = Math.floor(index / actualColumns) + 1; + maxRow = Math.max(maxRow, newRow); + return { + ...cell, + gridColumn: `${newCol} / ${newCol + 1}`, + gridRow: `${newRow} / ${newRow + 1}`, + }; + }); + + return { remappedCells: remapped, actualRows: maxRow }; +} + // ===== 메인 컴포넌트 ===== export function GridModeComponent({ @@ -32,9 +116,25 @@ export function GridModeComponent({ columns, rows, gap = 8, + containerWidth, renderItem, }: GridModeProps) { - if (!cells.length) { + // 반응형 열 수 계산 + const actualColumns = useMemo( + () => + containerWidth + ? computeResponsiveColumns(columns, containerWidth, gap) + : columns, + [columns, containerWidth, gap] + ); + + // 열 수가 줄었으면 셀 재배열 + const { remappedCells, actualRows } = useMemo( + () => remapCells(cells, columns, actualColumns, rows), + [cells, columns, actualColumns, rows] + ); + + if (!remappedCells.length) { return (
셀 없음 @@ -47,12 +147,12 @@ export function GridModeComponent({ className="h-full w-full" style={{ display: "grid", - gridTemplateColumns: `repeat(${columns}, 1fr)`, - gridTemplateRows: `repeat(${rows}, 1fr)`, + gridTemplateColumns: `repeat(${actualColumns}, 1fr)`, + gridTemplateRows: `repeat(${actualRows}, 1fr)`, gap: `${gap}px`, }} > - {cells.map((cell) => ( + {remappedCells.map((cell) => (
- 빈 셀 + + 빈 셀 +
)}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 74dfcd4c..64860699 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -12,8 +12,14 @@ import { dashboardApi } from "@/lib/api/dashboard"; import { dataApi } from "@/lib/api/data"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import type { TableInfo } from "@/lib/api/tableManagement"; import type { DataSourceConfig, DataSourceFilter } from "../../types"; +// ===== 타입 re-export ===== + +export type { TableInfo }; + // ===== 반환 타입 ===== export interface AggregatedResult { @@ -233,3 +239,21 @@ export async function fetchTableColumns( return []; } } + +/** + * 테이블 목록 조회 (설정 패널 Combobox용) + * tableManagementApi.getTableList() 래핑 + * + * @INFRA-EXTRACT: useDataSource 완성 후 교체 예정 + */ +export async function fetchTableList(): Promise { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + return response.data; + } + return []; + } catch { + return []; + } +} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 4e8ae079..e5927787 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -194,7 +194,6 @@ export interface PopActionConfig { export type DashboardDisplayMode = | "arrows" | "auto-slide" - | "grid" | "scroll"; export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card"; export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio"; @@ -280,6 +279,17 @@ export interface DashboardCell { itemId: string | null; // null이면 빈 셀 } +// ----- 대시보드 페이지(슬라이드) ----- + +/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */ +export interface DashboardPage { + id: string; + label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1") + gridColumns: number; // 이 페이지의 열 수 + gridRows: number; // 이 페이지의 행 수 + gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정) +} + // ----- 대시보드 아이템 ----- export interface DashboardItem { @@ -306,17 +316,18 @@ export interface DashboardItem { export interface PopDashboardConfig { items: DashboardItem[]; - displayMode: DashboardDisplayMode; + pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃) + displayMode: DashboardDisplayMode; // 페이지 간 전환 방식 // 모드별 설정 autoSlideInterval?: number; // 초 (기본 5) autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3) - gridCells?: DashboardCell[]; // grid 모드 셀 배치 - gridColumns?: number; // grid 모드 열 수 (기본 2) - gridRows?: number; // grid 모드 행 수 (기본 2) // 공통 스타일 showIndicator?: boolean; // 페이지 인디케이터 gap?: number; // 아이템 간 간격 px backgroundColor?: string; + + // 데이터 소스 (아이템 공통) + dataSource?: DataSourceConfig; } From 578cca2687bcfbad541183e1cefc399ac271df12 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 16:12:29 +0900 Subject: [PATCH 07/55] =?UTF-8?q?feat(pop-dashboard):=204=EA=B0=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=99=84=EC=84=B1=20-=20=EC=84=A4=EC=A0=95=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 (PopDashboardConfig): - groupBy(X축 분류) Combobox 설정 UI 추가 - 차트 xAxisColumn/yAxisColumn 입력 UI 추가 - 통계 카드 카테고리 추가/삭제/편집 인라인 에디터 추가 - 대상 컬럼 Select를 Combobox(검색 가능)로 개선 데이터 처리 버그 수정 (PopDashboardComponent): - 차트: groupBy 있을 때 xAxisColumn 자동 보정 로직 추가 - 통계 카드: 카테고리별 필터 실제 적용 (기존: 모든 카테고리에 rows.length 동일 입력) - useCallback 의존성 안정화 (visibleItemIds 문자열 키 사용) - refreshInterval 최소 5초 강제 데이터 fetcher 방어 로직 (dataFetcher.ts): - validateDataSourceConfig() 추가: 설정 미완료 시 SQL 전송 차단 - 빈 필터/불완전 조인 건너뜀 처리 - COUNT 컬럼 미선택 시 COUNT(*) 자동 처리 - fetchTableColumns() 이중 폴백 (tableManagementApi -> dashboardApi) 아이템 UI 개선: - KPI/차트/게이지/통계 카드 패딩 및 폰트 크기 조정 - 작은 셀에서도 라벨/단위/증감율 표시되도록 hidden 제거 기타: - GridMode MIN_CELL_WIDTH 160 -> 80 축소 - PLAN.MD: 대시보드 4가지 아이템 모드 완성 계획으로 갱신 - STATUS.md: 프로젝트 상태 추적 파일 추가 Co-authored-by: Cursor --- PLAN.MD | 536 +++++++++++++++--- STATUS.md | 48 ++ .../src/controllers/screenGroupController.ts | 2 +- .../pop-dashboard/PopDashboardComponent.tsx | 60 +- .../pop-dashboard/PopDashboardConfig.tsx | 412 ++++++++++++-- .../pop-dashboard/items/ChartItem.tsx | 4 +- .../pop-dashboard/items/GaugeItem.tsx | 6 +- .../pop-dashboard/items/KpiCard.tsx | 14 +- .../pop-dashboard/items/StatCard.tsx | 6 +- .../pop-dashboard/modes/GridMode.tsx | 2 +- .../pop-dashboard/utils/dataFetcher.ts | 90 ++- 11 files changed, 1030 insertions(+), 150 deletions(-) create mode 100644 STATUS.md diff --git a/PLAN.MD b/PLAN.MD index e4f4e424..45468fa4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,135 +1,527 @@ -# 현재 구현 계획: POP 뷰어 스크롤 수정 +# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 -> **작성일**: 2026-02-09 -> **상태**: 계획 완료, 코딩 대기 -> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정 +> **작성일**: 2026-02-10 +> **상태**: 코딩 완료 (방어 로직 패치 포함) +> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 --- ## 1. 문제 요약 -설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만, -뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임. +pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. -**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단 - -| # | 컨테이너 (라인) | 현재 클래스 | 문제 | -|---|----------------|-------------|------| -| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 | -| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 | -| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 | +| # | 문제 | 심각도 | 영향 | +|---|------|--------|------| +| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | +| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | +| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | +| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | --- -## 2. 수정 대상 파일 (1개) +## 2. 수정 대상 파일 (2개) -### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` +### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` -**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음) +**변경 유형**: 설정 UI 추가 3건 -#### 변경 1: 라인 185 - 최외곽 컨테이너 +#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) -**현재 코드**: +집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. + +**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 + +**추가할 코드** (약 50줄): + +```tsx +{/* 그룹핑 (차트용 X축 분류) */} +{dataSource.aggregation && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + const current = dataSource.aggregation?.groupBy ?? []; + const isSelected = current.includes(col.name); + const newGroupBy = isSelected + ? current.filter((g) => g !== col.name) + : [...current, col.name]; + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, + }, + }); + setGroupByOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.type}) + + ))} + + + + + +

+ 차트에서 X축 카테고리로 사용됩니다 +

+
+)} ``` -
+ +**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆): + +```tsx +const [groupByOpen, setGroupByOpen] = useState(false); +``` + +#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근) + +**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음 + +**추가할 코드** (약 30줄): + +```tsx +{/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +

+
+``` + +#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음) + +**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가 + +**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록 + +```tsx +{item.subType === "stat-card" && ( +
+
+ + +
+ + {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( +
+
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, label: e.target.value }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="라벨 (예: 수주)" + className="h-6 flex-1 text-xs" + /> + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, color: e.target.value || undefined }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="#색상코드" + className="h-6 w-20 text-xs" + /> + +
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */} +
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, column: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="컬럼" + className="h-6 w-20 text-[10px]" + /> + + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, value: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> +
+
+ ))} + + {(item.statConfig?.categories ?? []).length === 0 && ( +

+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +

+ )} +
+)} +``` + +--- + +### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx` + +**변경 유형**: 데이터 처리 로직 수정 2건 + +#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근) + +차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영. + +**현재 코드** (라인 276~283): +```tsx +case "chart": + return ( + + ); ``` **변경 코드**: -``` -
+```tsx +case "chart": { + // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 + const chartItem = { ...item }; + if ( + item.dataSource.aggregation?.groupBy?.length && + !item.chartConfig?.xAxisColumn + ) { + chartItem.chartConfig = { + ...chartItem.chartConfig, + chartType: chartItem.chartConfig?.chartType ?? "bar", + xAxisColumn: item.dataSource.aggregation.groupBy[0], + }; + } + return ( + + ); +} ``` -**변경 내용**: `overflow-hidden` 제거 -**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거 +#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) -#### 변경 2: 라인 266 - 컨텐츠 영역 - -**현재 코드**: -``` -
+**현재 코드** (버그): +```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값 + } + } + return ( + + ); +} ``` **변경 코드**: -``` -
+```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + if (cat.filter.column && cat.filter.value) { + // 카테고리 필터로 rows 필터링 + const filtered = itemData.rows.filter((row) => { + const cellValue = String(row[cat.filter.column] ?? ""); + const filterValue = String(cat.filter.value ?? ""); + switch (cat.filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case "like": + return cellValue.toLowerCase().includes(filterValue.toLowerCase()); + default: + return cellValue === filterValue; + } + }); + categoryData[cat.label] = filtered.length; + } else { + categoryData[cat.label] = itemData.rows.length; + } + } + } + return ( + + ); +} ``` -**변경 내용**: `overflow-auto`를 조건문 밖으로 이동 (공통 적용) -**이유**: 프리뷰/일반 모드 모두 스크롤이 필요함 - -#### 변경 3: 라인 275 - 백색 배경 컨테이너 - -**현재 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`} -``` - -**변경 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`} -``` - -**변경 내용**: 일반 모드에 `min-h-full` 추가 -**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장 +**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. --- ## 3. 구현 순서 (의존성 기반) -| 순서 | 작업 | 라인 | 의존성 | 상태 | +| 순서 | 작업 | 파일 | 의존성 | 상태 | |------|------|------|--------|------| -| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 | -| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 | -| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 | -| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 | -| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 | +| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] | +| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] | +| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] | +| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] | +| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] | +| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] | + +순서 1, 2, 3은 서로 독립이므로 병렬 가능. +순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. +순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음. +순서 7, 8은 백엔드 부하 방지를 위한 방어 패치. --- ## 4. 사전 충돌 검사 결과 -**새로 추가할 변수/함수/타입: 없음** +### 새로 추가할 식별자 목록 -이번 수정은 기존 Tailwind CSS 클래스 문자열만 변경합니다. -새로운 식별자(변수, 함수, 타입)를 추가하지 않으므로 충돌 검사 대상이 없습니다. +| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | +|--------|------|-----------|-----------|-----------| +| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 | + +**Grep 검색 결과** (전체 pop-dashboard 폴더): +- `groupByOpen`: 0건 - 충돌 없음 +- `setGroupByOpen`: 0건 - 충돌 없음 +- `groupByColumns`: 0건 - 충돌 없음 +- `chartItem`: 0건 - 충돌 없음 +- `StatCategoryEditor`: 0건 - 충돌 없음 +- `loadCategoryData`: 0건 - 충돌 없음 + +### 기존 타입/함수 재사용 목록 + +| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | +|------------|-----------|------------------------| +| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 | +| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 | +| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 | +| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 | +| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select | +| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 | + +**사용처 있는데 정의 누락된 항목: 없음** --- ## 5. 에러 함정 경고 -### 함정 1: 순서 1만 하고 순서 2를 빼먹으면 -`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨. -부모는 열었지만 자식에 스크롤 속성이 없는 상태. +### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 +ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. +`name` 키가 없으므로 X축이 빈 채로 렌더링됨. +**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. -### 함정 2: 순서 2만 하고 순서 1을 빼먹으면 -자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨. -**반드시 순서 1과 2를 함께 적용해야 함.** +### 함정 2: 통계 카드에 집계 함수를 설정하면 +집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. +카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. +통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. +설정 가이드 문서에 이 점을 명시해야 함. -### 함정 3: 프리뷰 모드 영향 -프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음. -`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음. +### 함정 3: PopDashboardConfig.tsx의 import 누락 +현재 `FilterOperator`는 이미 import되어 있음 (라인 54). +`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요. +**새로운 import 추가 필요 없음.** + +### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교 +`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨. +`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음. +현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의. + +### 함정 5: DataSourceEditor의 columns state 타이밍 +`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. +기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음. --- ## 6. 검증 방법 -1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준) -2. 화면 아래로 스크롤 가능한지 확인 -3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인 -4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인 -5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인 +### 차트 (BUG-1, BUG-2) +1. 아이템 추가 > "차트" 선택 +2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status` +3. 차트 유형: 막대 차트 +4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1 + +### 통계 카드 (BUG-3, BUG-4) +1. 아이템 추가 > "통계 카드" 선택 +2. 테이블: `sales_order_mng`, **집계: 없음** (중요!) +3. 카테고리 추가: + - "수주" / status / = / 수주 + - "진행중" / status / = / 진행중 + - "완료" / status / = / 완료 +4. 기대 결과: 수주 79, 진행중 7, 완료 1 --- ## 이전 완료 계획 (아카이브) +
+POP 뷰어 스크롤 수정 (완료) + +- [x] 라인 185: overflow-hidden 제거 +- [x] 라인 266: overflow-auto 공통 적용 +- [x] 라인 275: 일반 모드 min-h-full 추가 +- [x] 린트 검사 통과 + +
+
POP 뷰어 실제 컴포넌트 렌더링 (완료) - [x] 뷰어 페이지에 레지스트리 초기화 import 추가 -- [x] `renderActualComponent()` 실제 컴포넌트 렌더링으로 교체 +- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - [x] 린트 검사 통과 -- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..5e83ff11 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,48 @@ +# 프로젝트 상태 추적 + +> **최종 업데이트**: 2026-02-10 + +--- + +## 현재 진행 중 + +### pop-dashboard 4가지 아이템 모드 완성 +**상태**: 코딩 완료, 브라우저 테스트 대기 +**계획서**: [PLAN.MD](./PLAN.MD) + +--- + +## 다음 작업 + +| 순서 | 작업 | 파일 | 상태 | +|------|------|------|------| +| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 | + +--- + +## 완료된 작업 (최근) + +| 날짜 | 작업 | 비고 | +|------|------|------| +| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI | +| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 | +| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 | +| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 | +| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 | +| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 | +| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | +| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | +| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | + +--- + +## 알려진 이슈 + +| # | 이슈 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) | +| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) | +| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) | +| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) | +| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..b53454b9 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 0f1aba1a..97c4df97 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -187,6 +187,9 @@ export function PopDashboardComponent({ return () => observer.disconnect(); }, []); + // 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지) + const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id)); + // 데이터 로딩 함수 // eslint-disable-next-line react-hooks/exhaustive-deps const fetchAllData = useCallback(async () => { @@ -214,15 +217,18 @@ export function PopDashboardComponent({ setDataMap(newDataMap); setLoading(false); - }, [JSON.stringify(visibleItems.map((i) => i.id))]); + }, [visibleItemIds]); // 초기 로딩 + 주기적 새로고침 useEffect(() => { fetchAllData(); - // refreshInterval 적용 (첫 번째 아이템 기준) - const refreshSec = visibleItems[0]?.dataSource.refreshInterval; - if (refreshSec && refreshSec > 0) { + // refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제) + const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval; + const refreshSec = rawRefreshSec && rawRefreshSec > 0 + ? Math.max(5, rawRefreshSec) + : 0; + if (refreshSec > 0) { refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000); } @@ -232,7 +238,9 @@ export function PopDashboardComponent({ refreshTimerRef.current = null; } }; - }, [fetchAllData, visibleItems]); + // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchAllData, visibleItemIds]); // 빈 설정 (모든 hooks 이후에 early return) if (!config || !config.items?.length) { @@ -273,23 +281,55 @@ export function PopDashboardComponent({ formulaDisplay={itemData.formulaDisplay} /> ); - case "chart": + case "chart": { + // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 + const chartItem = { ...item }; + if ( + item.dataSource.aggregation?.groupBy?.length && + !item.chartConfig?.xAxisColumn + ) { + chartItem.chartConfig = { + ...chartItem.chartConfig, + chartType: chartItem.chartConfig?.chartType ?? "bar", + xAxisColumn: item.dataSource.aggregation.groupBy[0], + }; + } return ( ); + } case "gauge": return ; case "stat-card": { - // StatCard: 카테고리별 건수 맵 구성 + // StatCard: 카테고리별 건수 맵 구성 (필터 적용) const categoryData: Record = {}; if (item.statConfig?.categories) { for (const cat of item.statConfig.categories) { - // 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준) - categoryData[cat.label] = itemData.rows.length; + if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") { + // 카테고리 필터로 rows 필터링 + const filtered = itemData.rows.filter((row) => { + const cellValue = String(row[cat.filter.column] ?? ""); + const filterValue = String(cat.filter.value ?? ""); + switch (cat.filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case "like": + return cellValue.toLowerCase().includes(filterValue.toLowerCase()); + default: + return cellValue === filterValue; + } + }); + categoryData[cat.label] = filtered.length; + } else { + // 필터 미설정 시 전체 건수 + categoryData[cat.label] = itemData.rows.length; + } } } return ( diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 1b0ec03c..66a56876 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -152,6 +152,10 @@ function DataSourceEditor({ // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); + const [columnOpen, setColumnOpen] = useState(false); + + // 그룹핑 컬럼 (차트 X축용) + const [groupByOpen, setGroupByOpen] = useState(false); // 마운트 시 테이블 목록 로드 useEffect(() => { @@ -285,32 +289,156 @@ function DataSourceEditor({ {dataSource.aggregation && (
- + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + column: col.name, + }, + }); + setColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + + ({col.type}) + + + ))} + + + + +
)}
+ {/* 그룹핑 (차트 X축 분류) */} + {dataSource.aggregation && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + const current = dataSource.aggregation?.groupBy ?? []; + const isSelected = current.includes(col.name); + const newGroupBy = isSelected + ? current.filter((g) => g !== col.name) + : [...current, col.name]; + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, + }, + }); + setGroupByOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.type}) + + ))} + + + + + +

+ 차트에서 X축 카테고리로 사용됩니다 +

+
+ )} + {/* 자동 새로고침 (Switch + 주기 입력) */}
@@ -1135,29 +1263,77 @@ function ItemEditor({ )} {item.subType === "chart" && ( -
- - +
+
+ + +
+ + {/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +

+
+ + {/* Y축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + yAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="집계 결과 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 집계 결과 컬럼. 비우면 집계 함수 결과를 자동 사용 +

+
)} @@ -1220,6 +1396,152 @@ function ItemEditor({
)} + + {/* 통계 카드 카테고리 설정 */} + {item.subType === "stat-card" && ( +
+
+ + +
+ + {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( +
+
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, label: e.target.value }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="라벨 (예: 수주)" + className="h-6 flex-1 text-xs" + /> + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, color: e.target.value || undefined }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="#색상코드" + className="h-6 w-20 text-xs" + /> + +
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */} +
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, column: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="컬럼" + className="h-6 w-20 text-[10px]" + /> + + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, value: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> +
+
+ ))} + + {(item.statConfig?.categories ?? []).length === 0 && ( +

+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +

+ )} +
+ )}
)}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index 66694a58..c1fbd6b6 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -84,10 +84,10 @@ export function ChartItemComponent({ } return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx index e2b5dd30..c7313a85 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -69,10 +69,10 @@ export function GaugeItemComponent({ const largeArcFlag = percentage > 50 ? 1 : 0; return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -128,7 +128,7 @@ export function GaugeItemComponent({ {/* 목표값 */} {visibility.showTarget && ( -

+

목표: {abbreviateNumber(target)}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 1cb09e74..29db2791 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -66,10 +66,10 @@ export function KpiCardComponent({ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -78,7 +78,7 @@ export function KpiCardComponent({ {visibility.showValue && (
{formulaDisplay ?? abbreviateNumber(displayValue)} @@ -86,7 +86,7 @@ export function KpiCardComponent({ {/* 단위 */} {visibility.showUnit && kpiConfig?.unit && ( - + {kpiConfig.unit} )} @@ -95,14 +95,12 @@ export function KpiCardComponent({ {/* 증감율 */} {visibility.showTrend && trendValue != null && ( -
- -
+ )} {/* 보조 라벨 (수식 표시 등) */} {visibility.showSubLabel && formulaDisplay && ( -

+

{item.formula?.values.map((v) => v.label).join(" / ")}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index f12e4e05..c3c02e7b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -37,10 +37,10 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -80,7 +80,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { {/* 보조 라벨 (단위 등) */} {visibility.showSubLabel && ( -

+

{visibility.showUnit && item.kpiConfig?.unit ? `단위: ${item.kpiConfig.unit}` : ""} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx index 66c4f5e9..5e339fc5 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -18,7 +18,7 @@ import type { DashboardCell } from "../../types"; // ===== 상수 ===== /** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ -const MIN_CELL_WIDTH = 160; +const MIN_CELL_WIDTH = 80; // ===== Props ===== diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 64860699..4746b69b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -46,13 +46,55 @@ function escapeSQL(value: unknown): string { return `'${str}'`; } +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +function validateDataSourceConfig(config: DataSourceConfig): string | null { + // 테이블명 필수 + if (!config.tableName || !config.tableName.trim()) { + return "테이블이 선택되지 않았습니다"; + } + + // 집계 함수가 설정되었으면 대상 컬럼도 필수 + // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) + if (config.aggregation) { + const aggType = config.aggregation.type?.toLowerCase(); + const aggCol = config.aggregation.column?.trim(); + if (aggType !== "count" && !aggCol) { + return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; + } + } + + // 조인이 있으면 조인 조건 필수 + if (config.joins?.length) { + for (const join of config.joins) { + if (!join.targetTable?.trim()) { + return "조인 대상 테이블이 선택되지 않았습니다"; + } + if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + return "조인 조건 컬럼이 설정되지 않았습니다"; + } + } + } + + return null; +} + // ===== 필터 조건 SQL 생성 ===== /** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ function buildWhereClause(filters: DataSourceFilter[]): string { - if (!filters.length) return ""; + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; - const conditions = filters.map((f) => { + const conditions = validFilters.map((f) => { const col = sanitizeIdentifier(f.column); switch (f.operator) { @@ -98,8 +140,18 @@ export function buildAggregationSQL(config: DataSourceConfig): string { let selectClause: string; if (config.aggregation) { const aggType = config.aggregation.type.toUpperCase(); - const aggCol = sanitizeIdentifier(config.aggregation.column); - selectClause = `${aggType}(${aggCol}) as value`; + const aggCol = config.aggregation.column?.trim() + ? sanitizeIdentifier(config.aggregation.column) + : ""; + + // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + if (!aggCol) { + selectClause = aggType === "COUNT" + ? "COUNT(*) as value" + : `${aggType}(${tableName}.*) as value`; + } else { + selectClause = `${aggType}(${aggCol}) as value`; + } // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 if (config.aggregation.groupBy?.length) { @@ -110,10 +162,14 @@ export function buildAggregationSQL(config: DataSourceConfig): string { selectClause = "*"; } - // FROM 절 (조인 포함) + // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) let fromClause = tableName; if (config.joins?.length) { for (const join of config.joins) { + // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) + if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + continue; + } const joinTable = sanitizeIdentifier(join.targetTable); const joinType = join.joinType.toUpperCase(); const srcCol = sanitizeIdentifier(join.on.sourceColumn); @@ -173,6 +229,12 @@ export async function fetchAggregatedData( config: DataSourceConfig ): Promise { try { + // 설정 완료 여부 검증 (미완료 시 SQL 전송 차단) + const validationError = validateDataSourceConfig(config); + if (validationError) { + return { value: 0, rows: [], error: validationError }; + } + // 집계 또는 조인이 있으면 SQL 직접 실행 if (config.aggregation || (config.joins && config.joins.length > 0)) { const sql = buildAggregationSQL(config); @@ -228,6 +290,24 @@ export async function fetchAggregatedData( export async function fetchTableColumns( tableName: string ): Promise { + // 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적) + try { + const response = await tableManagementApi.getTableSchema(tableName); + if (response.success && response.data) { + const cols = Array.isArray(response.data) ? response.data : []; + if (cols.length > 0) { + return cols.map((col: any) => ({ + name: col.columnName || col.column_name || col.name, + type: col.dataType || col.data_type || col.type || "unknown", + udtName: col.dbType || col.udt_name || col.udtName || "unknown", + })); + } + } + } catch { + // tableManagementApi 실패 시 dashboardApi로 폴백 + } + + // 2차: dashboardApi (fetch 기반, 폴백) try { const schema = await dashboardApi.getTableSchema(tableName); return schema.columns.map((col) => ({ From 7a71fc6ca7b5674c8dcc10b3e70ca7197aef9324 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 16:55:34 +0900 Subject: [PATCH 08/55] =?UTF-8?q?fix(pop-dashboard):=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20X/Y=EC=B6=95=20=EC=9E=90=EB=8F=99=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 간소화: - 차트 X축/Y축 수동 입력 필드 제거 (자동 적용 안내 문구로 대체) - groupBy 선택 시 X축 자동, 집계 결과를 Y축(value)으로 자동 매핑 차트 렌더링 개선 (ChartItem): - PieChart에 카테고리명+값+비율 라벨 표시 - Legend 컴포넌트 추가 (containerWidth 300px 이상 시) - Tooltip formatter로 이름/값 쌍 표시 데이터 fetcher 안정화 (dataFetcher): - apiClient(axios) 우선 호출, dashboardApi(fetch) 폴백 패턴 적용 - PostgreSQL bigint/numeric 문자열 -> 숫자 자동 변환 처리 - Recharts가 숫자 타입을 요구하는 문제 해결 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardConfig.tsx | 49 ++----------------- .../pop-dashboard/items/ChartItem.tsx | 23 +++++++-- .../pop-dashboard/utils/dataFetcher.ts | 38 ++++++++++++-- 3 files changed, 56 insertions(+), 54 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 66a56876..b75f9b66 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -1289,51 +1289,10 @@ function ItemEditor({

- {/* X축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - xAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="groupBy 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 -

-
- - {/* Y축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - yAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="집계 결과 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 집계 결과 컬럼. 비우면 집계 함수 결과를 자동 사용 -

-
+ {/* X축/Y축 자동 안내 */} +

+ X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용 +

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index c1fbd6b6..93f29b1c 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -19,6 +19,7 @@ import { XAxis, YAxis, Tooltip, + Legend, ResponsiveContainer, } from "recharts"; import type { DashboardItem } from "../../types"; @@ -124,7 +125,7 @@ export function ChartItemComponent({ /> ) : ( - /* pie */ + /* pie - 카테고리명 + 값 라벨 표시 */ []} @@ -132,8 +133,14 @@ export function ChartItemComponent({ nameKey={xKey} cx="50%" cy="50%" - outerRadius="80%" - label={containerWidth > 250} + outerRadius={containerWidth > 400 ? "70%" : "80%"} + label={ + containerWidth > 250 + ? ({ name, value, percent }: { name: string; value: number; percent: number }) => + `${name} ${value} (${(percent * 100).toFixed(0)}%)` + : false + } + labelLine={containerWidth > 250} > {rows.map((_, index) => ( ))} - + [value, name]} + /> + {containerWidth > 300 && ( + + )} )} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 4746b69b..c2baaa55 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -10,6 +10,7 @@ * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 */ +import { apiClient } from "@/lib/api/client"; import { dashboardApi } from "@/lib/api/dashboard"; import { dataApi } from "@/lib/api/data"; import { tableManagementApi } from "@/lib/api/tableManagement"; @@ -238,19 +239,46 @@ export async function fetchAggregatedData( // 집계 또는 조인이 있으면 SQL 직접 실행 if (config.aggregation || (config.joins && config.joins.length > 0)) { const sql = buildAggregationSQL(config); - const result = await dashboardApi.executeQuery(sql); - if (result.rows.length === 0) { + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: any[] }; + try { + // 1차: apiClient (axios 기반, 인증/세션 안정적) + const response = await apiClient.post("/dashboards/execute-query", { query: sql }); + if (response.data?.success && response.data?.data) { + queryResult = response.data.data; + } else { + throw new Error(response.data?.message || "쿼리 실행 실패"); + } + } catch { + // 2차: dashboardApi (fetch 기반, 폴백) + queryResult = await dashboardApi.executeQuery(sql); + } + + if (queryResult.rows.length === 0) { return { value: 0, rows: [] }; } + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 + // Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리 + const processedRows = queryResult.rows.map((row: Record) => { + const converted: Record = { ...row }; + for (const key of Object.keys(converted)) { + const val = converted[key]; + if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { + converted[key] = Number(val); + } + } + return converted; + }); + // 첫 번째 행의 value 컬럼 추출 - const firstRow = result.rows[0]; - const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0); + const firstRow = processedRows[0]; + const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)); return { value: Number.isFinite(numericValue) ? numericValue : 0, - rows: result.rows, + rows: processedRows, }; } From bd7bf69a99fcfbc2b5a7657e5a997d37b8888dfa Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 17:18:00 +0900 Subject: [PATCH 09/55] =?UTF-8?q?fix(pop-dashboard):=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C/=EB=AA=A8=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 패널 버그 수정 (PopDashboardConfig): - gaugeConfig 스프레드 순서 수정: min/max/target 값이 기존값에 덮어씌워지는 문제 해결 - 스프레드를 먼저 적용 후 변경 필드를 뒤에 배치하여 올바르게 반영 아이템 레이아웃 개선: - KpiCard/StatCard: items-center justify-center 추가로 셀 내 중앙 정렬 - GaugeItem: SVG를 flex-1 영역에서 반응형 렌더링 (h-full w-auto) - GaugeItem: preserveAspectRatio로 비율 유지, 라벨/목표값 shrink-0 모드 레이아웃 개선: - ArrowsMode: 아이템이 전체 영역 사용, 화살표/인디케이터를 overlay로 변경 - ArrowsMode: 화살표 크기 축소 (h-11 -> h-8), backdrop-blur 추가 - AutoSlideMode: 슬라이드 컨테이너를 absolute inset-0으로 전체 영역 활용 - AutoSlideMode: 인디케이터를 하단 overlay로 변경 Co-authored-by: Cursor --- .../pop-dashboard/PopDashboardConfig.tsx | 6 +-- .../pop-dashboard/items/GaugeItem.tsx | 16 ++++--- .../pop-dashboard/items/KpiCard.tsx | 2 +- .../pop-dashboard/items/StatCard.tsx | 2 +- .../pop-dashboard/modes/ArrowsMode.tsx | 39 ++++++++--------- .../pop-dashboard/modes/AutoSlideMode.tsx | 42 +++++++++---------- 6 files changed, 53 insertions(+), 54 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index b75f9b66..1c19a856 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -1307,9 +1307,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: parseInt(e.target.value) || 0, max: item.gaugeConfig?.max ?? 100, - ...item.gaugeConfig, }, }) } @@ -1325,9 +1325,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: parseInt(e.target.value) || 100, - ...item.gaugeConfig, }, }) } @@ -1343,9 +1343,9 @@ function ItemEditor({ onUpdate({ ...item, gaugeConfig: { + ...item.gaugeConfig, min: item.gaugeConfig?.min ?? 0, max: item.gaugeConfig?.max ?? 100, - ...item.gaugeConfig, target: parseInt(e.target.value) || undefined, }, }) diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx index c7313a85..cca20686 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -69,17 +69,21 @@ export function GaugeItemComponent({ const largeArcFlag = percentage > 50 ? 1 : 0; return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} - {/* 게이지 SVG */} -
- + {/* 게이지 SVG - 높이/너비 모두 반응형 */} +
+ {/* 배경 반원 (회색) */} +

목표: {abbreviateNumber(target)}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index 29db2791..13086587 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -66,7 +66,7 @@ export function KpiCardComponent({ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); return ( -
+
{/* 라벨 */} {visibility.showLabel && (

diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx index c3c02e7b..93cc1305 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -37,7 +37,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); return ( -

+
{/* 라벨 */} {visibility.showLabel && (

diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx index 51a05814..d91e6ea2 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx @@ -47,42 +47,37 @@ export function ArrowsModeComponent({ } return ( -

- {/* 콘텐츠 + 화살표 */} -
- {/* 왼쪽 화살표 */} - {itemCount > 1 && ( +
+ {/* 아이템 (전체 영역 사용) */} +
+ {renderItem(currentIndex)} +
+ + {/* 좌우 화살표 (콘텐츠 위에 겹침) */} + {itemCount > 1 && ( + <> - )} - - {/* 아이템 */} -
- {renderItem(currentIndex)} -
- - {/* 오른쪽 화살표 */} - {itemCount > 1 && ( - )} -
+ + )} - {/* 페이지 인디케이터 */} + {/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */} {showIndicator && itemCount > 1 && ( -
+
{Array.from({ length: itemCount }).map((_, i) => ( + ); + })} +
+
+
+ )} + +
+ ); + } + + // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return ( -
- {/* 헤더 */} -
- - {component.label || typeLabel} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} -
- - {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} -
- {PreviewComponent ? ( - - ) : ( - - {typeLabel} - - )} -
- - {/* 위치 정보 표시 (유효 위치 사용) */} -
- {effectivePosition.col},{effectivePosition.row} - ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) -
+ )}
); } From 1116fb350aedf55370a8906530b896835e5813a8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 10 Feb 2026 18:02:30 +0900 Subject: [PATCH 11/55] =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20UX=20=EA=B0=9C=EC=84=A0:=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=A0=9C=EA=B1=B0=20+=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20+=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../components/pop/designer/PopDesigner.tsx | 3 + .../designer/panels/ComponentEditorPanel.tsx | 55 ++++++++++++++++++- .../pop/designer/renderers/PopRenderer.tsx | 52 +++++++----------- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 8bcc8f3a..f4dfd3fa 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -652,6 +652,9 @@ export default function PopDesigner({ ? (updates) => handleUpdateComponent(selectedComponentId, updates) : undefined } + allComponents={Object.values(layout.components)} + onSelectComponent={setSelectedComponentId} + selectedComponentId={selectedComponentId} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ddb7ac79..0a9d6037 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,7 +7,6 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - PopComponentType, } from "../types/pop-layout"; import { Settings, @@ -16,6 +15,7 @@ import { Grid3x3, MoveHorizontal, MoveVertical, + Layers, } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; @@ -36,12 +36,21 @@ interface ComponentEditorPanelProps { onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; + /** 그리드에 배치된 모든 컴포넌트 */ + allComponents?: PopComponentDefinitionV5[]; + /** 컴포넌트 선택 콜백 */ + onSelectComponent?: (componentId: string) => void; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== -const COMPONENT_TYPE_LABELS: Record = { +const COMPONENT_TYPE_LABELS: Record = { + "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-dashboard": "대시보드", "pop-field": "필드", "pop-button": "버튼", "pop-list": "리스트", @@ -61,6 +70,9 @@ export default function ComponentEditorPanel({ currentMode, onUpdateComponent, className, + allComponents, + onSelectComponent, + selectedComponentId, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -118,7 +130,44 @@ export default function ComponentEditorPanel({ {/* 위치 탭 */} - + + {/* 배치된 컴포넌트 목록 */} + {allComponents && allComponents.length > 0 && ( +
+
+ + + 배치된 컴포넌트 ({allComponents.length}) + +
+
+ {allComponents.map((comp) => { + const label = comp.label + || COMPONENT_TYPE_LABELS[comp.type] + || comp.type; + const isActive = comp.id === selectedComponentId; + return ( + + ); + })} +
+
+
+ )} + +
+ ); + } + + // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return ( -
- {/* 헤더 */} -
- - {component.label || typeLabel} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} -
- - {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} -
- {PreviewComponent ? ( - - ) : ( - - {typeLabel} - - )} -
- - {/* 위치 정보 표시 (유효 위치 사용) */} -
- {effectivePosition.col},{effectivePosition.row} - ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) -
+ )}
); } From 929cfb2b611ba71fd5baf6e589c6dfc16eac8458 Mon Sep 17 00:00:00 2001 From: shin Date: Wed, 11 Feb 2026 10:41:30 +0900 Subject: [PATCH 12/55] =?UTF-8?q?feat(pop):=20=EC=95=84=EC=9D=B4=EC=BD=98?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pop-icon.tsx 신규 추가: 아이콘 컴포넌트 구현 - ComponentPalette: 아이콘 컴포넌트 팔레트 추가 - ComponentEditorPanel: 아이콘 편집 패널 추가 - PopRenderer: 아이콘 렌더링 지원 - pop-layout.ts: 아이콘 타입 정의 추가 - pop-text.tsx: 텍스트 컴포넌트 개선 - next.config.mjs: 설정 업데이트 Co-authored-by: Cursor --- POPUPDATE_2.md | 2 +- .../designer/panels/ComponentEditorPanel.tsx | 12 +- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 17 +- .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 1 + .../lib/registry/pop-components/pop-icon.tsx | 974 ++++++++++++++++++ .../lib/registry/pop-components/pop-text.tsx | 27 +- frontend/next.config.mjs | 11 +- 9 files changed, 1035 insertions(+), 20 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-icon.tsx diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md index c4da5c4e..85e20af2 100644 --- a/POPUPDATE_2.md +++ b/POPUPDATE_2.md @@ -361,7 +361,7 @@ DashboardItem { - **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음) - **카테고리**: action -- **역할**: 네비게이션 (화면 이동, URL 이동, 새로고침) +- **역할**: 네비게이션 (화면 이동, URL 이동) - **데이터**: 없음 - **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행) - **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시 diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 0a9d6037..847d8aed 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -109,8 +109,8 @@ export default function ComponentEditorPanel({
{/* 탭 */} - - + + 위치 @@ -130,7 +130,7 @@ export default function ComponentEditorPanel({ {/* 위치 탭 */} - + {/* 배치된 컴포넌트 목록 */} {allComponents && allComponents.length > 0 && (
@@ -178,7 +178,7 @@ export default function ComponentEditorPanel({ {/* 설정 탭 */} - + {/* 표시 탭 */} - + {/* 데이터 탭 */} - + diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 05db0aab..a0d628aa 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText } from "lucide-react"; +import { Square, FileText, MousePointer } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -27,6 +27,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: FileText, description: "텍스트, 시간, 이미지 표시", }, + { + type: "pop-icon", + label: "아이콘", + icon: MousePointer, + description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 633dbf5e..7f5a570b 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -54,6 +54,8 @@ interface PopRendererProps { overridePadding?: number; /** 추가 className */ className?: string; + /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */ + currentScreenId?: number; } // ======================================== @@ -83,6 +85,7 @@ export default function PopRenderer({ overrideGap, overridePadding, className, + currentScreenId, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -511,9 +514,19 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등) if (ActualComp) { + // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 + const needsPointerEvents = component.type === "pop-icon"; + return ( -
- +
+
); } diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 1a8335ec..f92791c9 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon"; // 테스트용 샘플 박스, 텍스트 컴포넌트, 아이콘 컴포넌트 /** * 데이터 흐름 정의 @@ -342,6 +342,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { export const DEFAULT_COMPONENT_GRID_SIZE: Record = { "pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 }, + "pop-icon": { colSpan: 1, rowSpan: 2 }, }; /** diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index b604f9e8..c73df551 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -13,6 +13,7 @@ export * from "./types"; // POP 컴포넌트 등록 import "./pop-text"; +import "./pop-icon"; // 향후 추가될 컴포넌트들: // import "./pop-field"; diff --git a/frontend/lib/registry/pop-components/pop-icon.tsx b/frontend/lib/registry/pop-components/pop-icon.tsx new file mode 100644 index 00000000..1d61afd9 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-icon.tsx @@ -0,0 +1,974 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { GridMode } from "@/components/pop/designer/types/pop-layout"; +import * as LucideIcons from "lucide-react"; +import { toast } from "sonner"; + +// ======================================== +// 타입 정의 +// ======================================== +export type IconType = "quick" | "emoji" | "image"; +export type IconSizeMode = "auto" | "fixed"; +export type LabelPosition = "bottom" | "right" | "none"; +export type NavigateMode = "none" | "screen" | "url" | "back"; + +export interface IconSizeByMode { + mobile_portrait: number; + mobile_landscape: number; + tablet_portrait: number; + tablet_landscape: number; +} + +export interface GradientConfig { + from: string; + to: string; + direction?: "to-b" | "to-r" | "to-br"; +} + +export interface ImageConfig { + fileObjid?: number; + imageUrl?: string; + // 임시 저장용 (브라우저 캐시) + tempDataUrl?: string; + tempFileName?: string; +} + +export interface PopIconAction { + type: "navigate"; + navigate: { + mode: NavigateMode; + screenId?: string; + url?: string; + }; +} + +export interface QuickSelectItem { + type: "lucide" | "emoji"; + value: string; + label: string; + gradient: GradientConfig; +} + +export interface PopIconConfig { + iconType: IconType; + // 빠른 선택용 + quickSelectType?: "lucide" | "emoji"; + quickSelectValue?: string; + // 이미지용 + imageConfig?: ImageConfig; + imageScale?: number; + // 공통 + label?: string; + labelPosition?: LabelPosition; + labelColor?: string; + labelFontSize?: number; + backgroundColor?: string; + gradient?: GradientConfig; + borderRadiusPercent?: number; + sizeMode: IconSizeMode; + fixedSize?: number; + sizeByMode?: IconSizeByMode; + action: PopIconAction; +} + +// ======================================== +// 상수 +// ======================================== +export const ICON_TYPE_LABELS: Record = { + quick: "빠른 선택", + emoji: "이모지 직접 입력", + image: "이미지", +}; + +// 섹션 구분선 컴포넌트 +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + +export const NAVIGATE_MODE_LABELS: Record = { + none: "없음", + screen: "POP 화면", + url: "외부 URL", + back: "뒤로가기", +}; + +export const LABEL_POSITION_LABELS: Record = { + bottom: "아래", + right: "오른쪽", + none: "없음", +}; + +export const DEFAULT_ICON_SIZE_BY_MODE: IconSizeByMode = { + mobile_portrait: 48, + mobile_landscape: 56, + tablet_portrait: 64, + tablet_landscape: 72, +}; + +// 빠른 선택 아이템 (Lucide 10개 + 이모지) +export const QUICK_SELECT_ITEMS: QuickSelectItem[] = [ + // 기본 아이콘 (Lucide) - 10개 + { type: "lucide", value: "Home", label: "홈", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "lucide", value: "ArrowLeft", label: "뒤로", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "lucide", value: "Settings", label: "설정", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "lucide", value: "Search", label: "검색", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "lucide", value: "Plus", label: "추가", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "lucide", value: "Check", label: "확인", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "lucide", value: "X", label: "취소", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "lucide", value: "Edit", label: "수정", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "lucide", value: "Trash2", label: "삭제", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "lucide", value: "RefreshCw", label: "새로고침", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + // 이모지 + { type: "emoji", value: "📋", label: "작업지시", gradient: { from: "#ff6b6b", to: "#ee5a5a" } }, + { type: "emoji", value: "📊", label: "생산실적", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + { type: "emoji", value: "📦", label: "입고", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "emoji", value: "🚚", label: "출고", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "emoji", value: "📈", label: "재고현황", gradient: { from: "#6c5ce7", to: "#5b4cdb" } }, + { type: "emoji", value: "🔍", label: "품질검사", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "emoji", value: "⚠️", label: "불량관리", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "emoji", value: "⚙️", label: "설비관리", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "emoji", value: "🦺", label: "안전관리", gradient: { from: "#f39c12", to: "#e67e22" } }, + { type: "emoji", value: "🏭", label: "외주", gradient: { from: "#6c5ce7", to: "#5b4cdb" } }, + { type: "emoji", value: "↩️", label: "반품", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "emoji", value: "🤝", label: "사급자재", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "emoji", value: "🔄", label: "교환", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + { type: "emoji", value: "📍", label: "재고이동", gradient: { from: "#4ecdc4", to: "#26a69a" } }, +]; + +// ======================================== +// 헬퍼 함수 +// ======================================== +function getIconSizeForMode(config: PopIconConfig | undefined, gridMode: GridMode): number { + if (!config) return DEFAULT_ICON_SIZE_BY_MODE[gridMode]; + if (config.sizeMode === "fixed" && config.fixedSize) { + return config.fixedSize; + } + const sizes = config.sizeByMode || DEFAULT_ICON_SIZE_BY_MODE; + return sizes[gridMode]; +} + +function buildGradientStyle(gradient?: GradientConfig): React.CSSProperties { + if (!gradient) return {}; + const direction = gradient.direction || "to-b"; + const dirMap: Record = { + "to-b": "to bottom", + "to-r": "to right", + "to-br": "to bottom right" + }; + return { + background: `linear-gradient(${dirMap[direction]}, ${gradient.from}, ${gradient.to})`, + }; +} + +function getImageUrl(imageConfig?: ImageConfig): string | undefined { + if (!imageConfig) return undefined; + // 임시 저장된 이미지 우선 + if (imageConfig.tempDataUrl) return imageConfig.tempDataUrl; + if (imageConfig.fileObjid) return `/api/files/preview/${imageConfig.fileObjid}`; + return imageConfig.imageUrl; +} + +// Lucide 아이콘 동적 렌더링 +function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const IconComponent = (LucideIcons as any)[name]; + if (!IconComponent) return null; + return ; +} + +// screenId에서 실제 ID만 추출 (URL이 입력된 경우 처리) +function extractScreenId(input: string): string { + if (!input) return ""; + + // URL 형태인 경우 (/pop/screens/123 또는 http://...pop/screens/123) + const urlMatch = input.match(/\/pop\/screens\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + + // http:// 또는 https://로 시작하는 경우 (다른 URL 형태) + if (input.startsWith("http://") || input.startsWith("https://")) { + // URL에서 마지막 숫자 부분 추출 시도 + const lastNumberMatch = input.match(/\/(\d+)\/?$/); + if (lastNumberMatch) { + return lastNumberMatch[1]; + } + } + + // 숫자만 있는 경우 그대로 반환 + if (/^\d+$/.test(input.trim())) { + return input.trim(); + } + + // 그 외의 경우 원본 반환 (에러 처리는 호출부에서) + return input; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== +interface PopIconComponentProps { + config?: PopIconConfig; + label?: string; + isDesignMode?: boolean; + gridMode?: GridMode; +} + +export function PopIconComponent({ + config, + label, + isDesignMode, + gridMode = "tablet_landscape" +}: PopIconComponentProps) { + const router = useRouter(); + const iconType = config?.iconType || "quick"; + const iconSize = getIconSizeForMode(config, gridMode); + + // 디자인 모드 확인 다이얼로그 상태 + const [showNavigateDialog, setShowNavigateDialog] = useState(false); + const [pendingNavigate, setPendingNavigate] = useState<{ mode: string; target: string } | null>(null); + + // 클릭 핸들러 + const handleClick = () => { + const navigate = config?.action?.navigate; + if (!navigate || navigate.mode === "none") return; + + // 디자인 모드: 확인 다이얼로그 표시 + if (isDesignMode) { + if (navigate.mode === "screen") { + if (!navigate.screenId) { + toast.error("화면 ID가 설정되지 않았습니다."); + return; + } + const cleanScreenId = extractScreenId(navigate.screenId); + setPendingNavigate({ mode: "screen", target: cleanScreenId }); + setShowNavigateDialog(true); + } else if (navigate.mode === "url") { + if (!navigate.url) { + toast.error("URL이 설정되지 않았습니다."); + return; + } + setPendingNavigate({ mode: "url", target: navigate.url }); + setShowNavigateDialog(true); + } else if (navigate.mode === "back") { + toast.warning("뒤로가기는 실제 화면에서 테스트해주세요."); + } + return; + } + + // 실제 모드: 직접 실행 + switch (navigate.mode) { + case "screen": + if (navigate.screenId) { + const cleanScreenId = extractScreenId(navigate.screenId); + window.location.href = `/pop/screens/${cleanScreenId}`; + } + break; + case "url": + if (navigate.url) window.location.href = navigate.url; + break; + case "back": + router.back(); + break; + } + }; + + // 확인 후 이동 실행 + const handleConfirmNavigate = () => { + if (!pendingNavigate) return; + + if (pendingNavigate.mode === "screen") { + const targetUrl = `/pop/screens/${pendingNavigate.target}`; + console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl }); + window.location.href = targetUrl; + } else if (pendingNavigate.mode === "url") { + console.log("[PopIcon] URL 이동:", pendingNavigate.target); + window.location.href = pendingNavigate.target; + } + + setShowNavigateDialog(false); + setPendingNavigate(null); + }; + + // 배경 스타일 (이미지 타입일 때는 배경 없음) + const backgroundStyle: React.CSSProperties = iconType === "image" + ? { backgroundColor: "transparent" } + : config?.gradient + ? buildGradientStyle(config.gradient) + : { backgroundColor: config?.backgroundColor || "#e0e0e0" }; + + // 테두리 반경 (0% = 사각형, 100% = 원형) + const radiusPercent = config?.borderRadiusPercent ?? 20; + const borderRadius = iconType === "image" ? "0%" : `${radiusPercent / 2}%`; + + // 라벨 위치에 따른 레이아웃 + const isLabelRight = config?.labelPosition === "right"; + const showLabel = config?.labelPosition !== "none" && (config?.label || label); + + // 아이콘 렌더링 + const renderIcon = () => { + // 빠른 선택 + if (iconType === "quick") { + if (config?.quickSelectType === "lucide" && config?.quickSelectValue) { + return ( + + ); + } else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) { + return {config.quickSelectValue}; + } + // 기본값 + return 📦; + } + + // 이모지 직접 입력 + if (iconType === "emoji") { + if (config?.quickSelectValue) { + return {config.quickSelectValue}; + } + return 📦; + } + + // 이미지 (배경 없이 이미지만 표시) + if (iconType === "image" && config?.imageConfig) { + const scale = config?.imageScale || 100; + return ( + + ); + } + + return 📦; + }; + + return ( +
+ {/* 아이콘 컨테이너 */} +
+ {renderIcon()} +
+ + {/* 라벨 */} + {showLabel && ( + + {config?.label || label} + + )} + + {/* 디자인 모드 네비게이션 확인 다이얼로그 */} + + + + 페이지 이동 확인 + + {pendingNavigate?.mode === "screen" + ? "POP 화면으로 이동합니다." + : "외부 URL로 이동합니다." + } +
+ + ※ 저장하지 않은 변경사항은 사라집니다. + +
+
+ + + 확인 후 이동 + + + +
+
+
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== +interface PopIconConfigPanelProps { + config: PopIconConfig; + onUpdate: (config: PopIconConfig) => void; +} + +export function PopIconConfigPanel({ config, onUpdate }: PopIconConfigPanelProps) { + const iconType = config?.iconType || "quick"; + + return ( +
+ {/* 아이콘 타입 선택 */} + +
+ +
+ + {/* 타입별 설정 */} + {iconType === "quick" && } + {iconType === "emoji" && } + {iconType === "image" && } + + {/* 라벨 설정 */} + + + + {/* 스타일 설정 (이미지 타입 제외) */} + {iconType !== "image" && ( + <> + + + + )} + + {/* 액션 설정 */} + + +
+ ); +} + +// 빠른 선택 그리드 +function QuickSelectGrid({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ +
+ {QUICK_SELECT_ITEMS.map((item, idx) => ( + + ))} +
+
+ ); +} + +// 이모지 직접 입력 +function EmojiInput({ config, onUpdate }: PopIconConfigPanelProps) { + const [customEmoji, setCustomEmoji] = useState(config?.quickSelectValue || ""); + + const handleEmojiChange = (value: string) => { + setCustomEmoji(value); + // 이모지가 입력되면 바로 적용 + if (value.trim()) { + onUpdate({ + ...config, + quickSelectType: "emoji", + quickSelectValue: value, + gradient: config?.gradient || { from: "#6c5ce7", to: "#5b4cdb" }, + }); + } + }; + + return ( +
+ + handleEmojiChange(e.target.value)} + placeholder="이모지를 입력하세요 (예: 📦, 🚀)" + className="h-8 text-xs" + maxLength={4} + /> +

+ Windows: Win + . / Mac: Ctrl + Cmd + Space +

+ + {/* 배경 그라디언트 설정 */} +
+
+ + onUpdate({ + ...config, + gradient: { ...config?.gradient, from: e.target.value, to: config?.gradient?.to || "#5b4cdb" } + })} + className="h-8 w-full p-1 cursor-pointer" + /> +
+
+ + onUpdate({ + ...config, + gradient: { ...config?.gradient, from: config?.gradient?.from || "#6c5ce7", to: e.target.value } + })} + className="h-8 w-full p-1 cursor-pointer" + /> +
+
+ + {/* 미리보기 */} + {customEmoji && ( +
+ {customEmoji} +
+ )} +
+ ); +} + +// 이미지 업로드 +function ImageUpload({ config, onUpdate }: PopIconConfigPanelProps) { + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + // 파일 선택 시 브라우저 캐시에 임시 저장 (DB 업로드 X) + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setError(null); + + // 이미지 파일 검증 + if (!file.type.startsWith("image/")) { + setError("이미지 파일만 선택할 수 있습니다."); + return; + } + + // 파일 크기 제한 (5MB) + if (file.size > 5 * 1024 * 1024) { + setError("파일 크기는 5MB 이하여야 합니다."); + return; + } + + // FileReader로 Base64 변환 (브라우저 캐시) + const reader = new FileReader(); + reader.onload = () => { + onUpdate({ + ...config, + imageConfig: { + tempDataUrl: reader.result as string, + tempFileName: file.name, + // 기존 DB 파일 정보 제거 + fileObjid: undefined, + imageUrl: undefined, + }, + }); + }; + reader.onerror = () => { + setError("파일을 읽는 중 오류가 발생했습니다."); + }; + reader.readAsDataURL(file); + + // input 초기화 (같은 파일 다시 선택 가능하도록) + e.target.value = ""; + }; + + // 이미지 삭제 + const handleDelete = () => { + onUpdate({ + ...config, + imageConfig: undefined, + imageScale: undefined, + }); + }; + + // 미리보기 URL 가져오기 + const getPreviewUrl = (): string | undefined => { + if (config?.imageConfig?.tempDataUrl) return config.imageConfig.tempDataUrl; + if (config?.imageConfig?.fileObjid) return `/api/files/preview/${config.imageConfig.fileObjid}`; + return config?.imageConfig?.imageUrl; + }; + + const previewUrl = getPreviewUrl(); + const hasImage = !!previewUrl; + const isTemp = !!config?.imageConfig?.tempDataUrl; + + return ( +
+ + + {/* 파일 선택 + 삭제 버튼 */} +
+ + + {hasImage && ( + + )} +
+ + {/* 에러 메시지 */} + {error &&

{error}

} + + {/* 또는 URL 직접 입력 */} + onUpdate({ + ...config, + imageConfig: { + imageUrl: e.target.value, + // URL 입력 시 임시 파일 제거 + tempDataUrl: undefined, + tempFileName: undefined, + fileObjid: undefined, + } + })} + placeholder="또는 URL 직접 입력..." + className="h-8 text-xs" + disabled={isTemp} + /> + + {/* 현재 이미지 미리보기 + 크기 조절 */} + {hasImage && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 미리보기 + {isTemp && ( + + 임시 + + )} +
+ {config?.imageConfig?.tempFileName && ( +

+ {config.imageConfig.tempFileName} +

+ )} + + onUpdate({ ...config, imageScale: Number(e.target.value) })} + className="w-full" + /> + {isTemp && ( +

+ ※ 화면 저장 시 서버에 업로드됩니다. +

+ )} +
+ )} +
+ ); +} + +// 라벨 설정 +function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ onUpdate({ ...config, label: e.target.value })} + placeholder="라벨 텍스트" + className="h-8 text-xs" + /> +
+ + onUpdate({ ...config, labelColor: e.target.value })} + className="h-8 w-12 p-1 cursor-pointer" + /> +
+ {/* 글자 크기 슬라이더 */} + + onUpdate({ ...config, labelFontSize: Number(e.target.value) })} + className="w-full" + /> +
+ ); +} + +// 스타일 설정 +function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ + onUpdate({ + ...config, + borderRadiusPercent: Number(e.target.value) + })} + className="w-full" + /> +
+ ); +} + +// 액션 설정 +function ActionSettings({ config, onUpdate }: PopIconConfigPanelProps) { + const navigate = config?.action?.navigate || { mode: "none" as NavigateMode }; + + return ( +
+ + + {/* 없음이 아닐 때만 추가 설정 표시 */} + {navigate.mode !== "none" && ( + <> + {navigate.mode === "screen" && ( + onUpdate({ + ...config, + action: { type: "navigate", navigate: { ...navigate, screenId: e.target.value } } + })} + placeholder="화면 ID" + className="h-8 text-xs mt-2" + /> + )} + {navigate.mode === "url" && ( + onUpdate({ + ...config, + action: { type: "navigate", navigate: { ...navigate, url: e.target.value } } + })} + placeholder="https://..." + className="h-8 text-xs mt-2" + /> + )} + + {/* 테스트 버튼 */} + + + )} +
+ ); +} + +// ======================================== +// 미리보기 컴포넌트 +// ======================================== +function PopIconPreviewComponent({ config }: { config?: PopIconConfig }) { + return ( +
+ +
+ ); +} + +// ======================================== +// 레지스트리 등록 +// ======================================== +PopComponentRegistry.registerComponent({ + id: "pop-icon", + name: "아이콘", + description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", + category: "action", + icon: "MousePointer", + component: PopIconComponent, + configPanel: PopIconConfigPanel, + preview: PopIconPreviewComponent, + defaultProps: { + iconType: "quick", + quickSelectType: "emoji", + quickSelectValue: "📦", + label: "아이콘", + labelPosition: "bottom", + labelColor: "#000000", + labelFontSize: 12, + borderRadiusPercent: 20, + sizeMode: "auto", + action: { type: "navigate", navigate: { mode: "none" } }, + } as PopIconConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx index 8cad19ad..dd51a158 100644 --- a/frontend/lib/registry/pop-components/pop-text.tsx +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -391,6 +391,19 @@ interface PopTextConfigPanelProps { onUpdate: (config: PopTextConfig) => void; } +// 섹션 구분선 컴포넌트 +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + export function PopTextConfigPanel({ config, onUpdate, @@ -398,10 +411,10 @@ export function PopTextConfigPanel({ const textType = config?.textType || "text"; return ( -
+
{/* 텍스트 타입 선택 */} +
-