# 2026-05-14 INV Studio Data View Roadmap

## 목적

INV Studio 에서 실제 운영 화면을 만들 수 있도록, 입력 필드 통합 다음 단계의 기준을 정리한다.

현재 목표는 특정 "인사 관리" 화면을 하드코딩하는 것이 아니다. 인사 관리 화면은 검증용 대표 예시이고, 최종 목표는 어떤 업무 도메인에서도 재사용 가능한 범용 화면 빌더 기능이다.

대표 예시:

- 상단 통계 카드: 전체 사원, 재직 중, 휴직 중, 겸직 현황
- 탭 1: 사원 목록
- 탭 2: 부서별 현황
- 탭 3: 근태 현황

이 화면을 스튜디오 설정만으로 만들 수 있어야 한다.

## 현재 원칙

1. 기존 V2/VEX 입력 경로를 되살리지 않는다.
2. 새 솔루션 개발 기준으로 간다. DB layout JSON 마이그레이션 SQL 을 작성하지 않는다.
3. FieldConfig / DataPort 계약은 유지한다. 축소하거나 다시 설계하지 않는다.
4. 인사 관리 전용 하드코딩을 금지한다.
5. 화면 구성 요소는 범용 설정으로 동작해야 한다.
6. 통계 카드, 탭, 테이블, 차트, 리스트는 모두 실제 테이블 데이터를 읽어 렌더링해야 한다.

## 현재 상태

### Canonical Input

입력 타입은 대부분 canonical `input` / InvInput 경로로 통합된 상태로 본다.

완료된 범위:

- text / number / date / datetime / time / daterange / textarea
- single choice / multi choice
- static list / code / category / entity code-name
- checkbox list / swap / tag input
- boolean switch / checkbox / yes-no button
- mask / slider / color
- file / image

남은 작업 성격:

- 새 구조 작업보다는 운영 화면에서 이상 동작과 잔여 문구를 잡는 단계
- 전체 `tsc` 는 기존 camelCase / snake_case 전역 오류 때문에 실패 중
- 변경 파일 기준 신규 오류 여부를 필터링해서 확인하는 방식으로 진행

## 다음 큰 목표

입력필드 다음 단계는 다음 두 축이다.

1. 데이터 바인딩 가능한 통계 카드
2. 탭별로 다른 데이터 뷰를 가질 수 있는 탭 시스템

이 두 기능이 되어야 인사 관리 예시 화면을 만들 수 있다.

## 1. 데이터 바인딩 통계 카드

현재 필요한 것은 고정 숫자 카드가 아니라, 테이블 값을 계산해서 표시하는 카드다.

예시:

- 전체 사원: `user_info` count
- 재직 중: `user_info` count where `status = '재직'`
- 휴직 중: `user_info` count where `status = '휴직'`
- 겸직 현황: `user_dept_role` count or distinct employee count

### 필요한 설정

통계 카드 컴포넌트는 최소한 다음 설정을 가져야 한다.

```ts
type StatCardItem = {
  id: string;
  label: string;
  tableName: string;
  aggregation: "count" | "sum" | "avg" | "min" | "max" | "distinctCount";
  columnName?: string;
  filters?: Array<{
    column: string;
    operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "isNull" | "isNotNull" | "like";
    valueType: "static" | "field" | "user";
    value?: unknown;
    fieldRef?: string;
    userField?: "companyCode" | "userId" | "deptCode" | "userName";
  }>;
  subText?: string;
  subTextMode?: "static" | "ratio" | "template";
  icon?: string;
  color?: string;
};
```

### 런타임 요구

- 디자인 모드에서는 API 폭주를 막고 preview 값 또는 skeleton 을 보여준다.
- 운영 모드에서는 실제 데이터로 집계한다.
- 여러 카드가 같은 테이블을 보더라도 요청을 묶거나 캐시해야 한다.
- 실패 시 화면 전체가 죽지 않고 해당 카드만 fallback 표시한다.

## 2. 탭별 데이터 뷰 시스템

인사 관리 화면에서 `사원 목록`, `부서별 현황`, `근태 현황`은 단순히 같은 테이블의 필터 탭이 아니다. 탭마다 화면 구성이 다르다.

### 탭별 차이

`사원 목록`

- 검색 필터
- 사원 테이블
- 부서 등록 / 사원 등록 버튼
- 선택 체크박스 컬럼

`부서별 현황`

- 왼쪽 부서 카드 목록
- 부서 수정 / 삭제 액션
- 오른쪽 부서별 인원 막대 차트
- 직급별 분포 도넛 차트

`근태 현황`

- 기간 / 부서 / 사원 / 상태 필터
- 날짜별 그룹 row
- 근태 테이블

따라서 필요한 것은 단순 Tabs UI 가 아니라, 탭마다 독립적인 view config 를 가지는 범용 `Tabbed Data View` 이다.

### 필요한 설정

```ts
type TabbedViewConfig = {
  tabs: Array<{
    id: string;
    label: string;
    icon?: string;
    viewType: "table" | "split-list-chart" | "grouped-table" | "custom";
    dataSources?: DataSourceConfig[];
    filters?: FilterConfig[];
    components?: ViewComponentConfig[];
  }>;
  defaultTabId?: string;
};
```

`components` 는 탭 내부에 배치되는 범용 뷰 구성 요소다.

예:

- searchFilter
- dataTable
- cardList
- barChart
- donutChart
- actionButtons
- groupedRows

## 3. 데이터 소스 공통 모델

통계 카드, 테이블, 차트, 리스트가 각각 따로 데이터를 가져오면 중복과 버그가 늘어난다. 공통 DataSource 모델이 필요하다.

```ts
type DataSourceConfig = {
  id: string;
  tableName: string;
  alias?: string;
  columns?: string[];
  filters?: FilterConfig[];
  joins?: JoinConfig[];
  groupBy?: string[];
  orderBy?: Array<{ column: string; direction: "asc" | "desc" }>;
  limit?: number;
};
```

원칙:

- 하나의 탭은 여러 DataSource 를 가질 수 있다.
- 차트와 테이블은 같은 DataSource 를 공유할 수 있다.
- 필터 컴포넌트는 같은 탭의 DataSource 에 값을 주입할 수 있어야 한다.
- DataPort 와 충돌하지 않게, 화면 내 데이터 흐름은 기존 계약 위에서 확장한다.

## 4. 구현 단계

### Phase G.0 - 현재 컴포넌트 인벤토리

목표:

- 현재 stats / table / tabs / chart / container 관련 컴포넌트와 registry 경로 조사
- 옛 V2 경로와 canonical 경로 구분
- 어떤 컴포넌트를 흡수하고 어떤 컴포넌트를 재사용할지 결정

산출물:

- 관련 파일 목록
- 현재 런타임 경로
- 폐기 대상 / 유지 대상 목록

### Phase G.1 - Data-bound Stats

목표:

- 통계 카드가 실제 테이블 aggregation 결과를 표시하도록 구현
- count / sum / avg / distinctCount 우선 지원
- static mock 값 의존 제거

범위:

- stats runtime loader
- stats config panel
- filter resolver 재사용
- 디자인 모드 API 가드

검증 기준:

- `user_info` 전체 count 표시
- `status = 재직` count 표시
- 필터 변경 시 값 갱신
- API 실패 시 카드 단위 fallback

### Phase G.2 - Tabbed Data View

목표:

- 탭별로 다른 view config 를 저장하고 렌더링
- 탭마다 다른 DataSource / filter / component layout 허용

범위:

- tabs runtime
- tabs config panel
- default tab / active tab 처리
- 탭별 hidden/render guard

검증 기준:

- `사원 목록`, `부서별 현황`, `근태 현황` 3탭 구성 가능
- 탭 전환 시 각 탭의 다른 뷰가 표시
- 탭별 설정이 서로 섞이지 않음

### Phase G.3 - View Components

목표:

- 탭 안에서 사용할 범용 뷰 컴포넌트를 정리

우선순위:

1. dataTable
2. searchFilter
3. cardList
4. barChart
5. donutChart
6. groupedTable

검증 기준:

- 사원 목록 테이블 구성 가능
- 부서 카드 목록 구성 가능
- 부서별 인원 막대 차트 구성 가능
- 직급별 분포 도넛 차트 구성 가능
- 근태 날짜 그룹 테이블 구성 가능

### Phase G.4 - 인사 관리 화면 검증

목표:

- 범용 설정만으로 인사 관리 예시 화면을 재현한다.

검증 화면:

- 상단 통계 카드 4개
- 사원 목록 탭
- 부서별 현황 탭
- 근태 현황 탭

중요:

- 화면 이름과 예시 데이터는 인사 관리여도 구현은 범용이어야 한다.
- 인사 관리 전용 if / hardcode 금지.

### Phase G.5 - Legacy Cleanup

목표:

- stats / tabs / old table view 관련 옛 경로가 남아 있으면 제거
- 단, 실제 재사용 가능한 shared util 은 유지

검증:

- 새 생성 경로가 canonical component 만 사용
- 옛 V2/VEX id 로 신규 생성되지 않음
- 기존 FieldConfig / DataPort 계약 영향 0건

## 금지 사항

- `v2-input`, `v2-select` 재도입 금지
- old file/media 컴포넌트 부활 금지
- `EntityPicker` 를 canonical input 에 재도입 금지
- 인사 관리 전용 hardcode 금지
- DB layout JSON migration SQL 금지
- FieldConfig / DataPort 축소 금지
- 화면을 단순 mock 정적 값으로 마무리 금지

## 다음 작업 프롬프트 기준

다음 작업자는 먼저 이 문서와 아래 문서를 읽고 시작한다.

- `notes/gbpark/2026-05-14-studio-data-view-roadmap.md`
- `notes/gbpark/2026-05-12-codex-handoff-input-canonical.md`
- 필요 시 `notes/gbpark/2026-05-08-input-canonical-migration.md`

다음 작업 시작점은 Phase G.0 이다.

먼저 현재 repo 의 stats / tabs / table / chart / container 관련 컴포넌트와 registry 경로를 조사하고, 새 범용 Data-bound Stats / Tabbed Data View 를 어디에 붙이는 것이 가장 안전한지 제안한다.

코드 변경은 조사 결과를 보고 Phase G.1 부터 시작한다.

---

## Phase G.0 Inventory Results

조사일: 2026-05-14. 브랜치 `gbpark-node`. 코드 변경 없음 (read-only).

### 0. 검증 사전 통과

```
git diff --check                                           PASS
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"   0 matches
rg "EntityPicker|entity-picker|EntitySearchModal"          0 matches (input + InvFieldConfigPanel)
rg 'componentType: "v2-file-upload"|file: "v2-file-upload"|image: "v2-file-upload"|img: "v2-file-upload"'  0 matches
```

canonical input cleanup 상태 그대로 유지됨. G.0 인벤토리에서 추가 정리 필요 없음.

### 1. Stats / KPI 컴포넌트

#### 1.1 canonical `stats` (★ G.1 의 진입점)

- 폴더: `frontend/lib/registry/components/stats/`
  - `StatsComponent.tsx` — card / chip / bigNumber 3 가지 style, horizontal / vertical / grid 3 가지 orientation
  - `InvStatsConfigPanel.tsx` — cp 시스템 통합 (title / dataSource / layout / colorPreset / items)
  - `StatsRenderer.tsx` — 자가 등록 renderer
  - `types.ts` — `StatsConfig`, `StatsItem`, `StatsAggregation`
  - `index.ts` — `ComponentDefinition` 선언, `dataPorts.inputs: [{ name: "data", type: "rows" }]` 이미 선언됨
- ComponentType: `"stats"`
- ConfigPanel routing: `getComponentConfigPanel.tsx` line 21 — `"stats" → InvStatsConfigPanel`
- 현재 상태: **표시 로직만**. `items[].value` 가 static. `sourceTable` 필드는 받지만 fetching 없음. 주석에 "Phase F 에서 연결" 표시
- alias (`CONFIG_PANEL_ALIAS`): `v2-aggregation-widget → stats`, `v2-status-count → stats`, `aggregation-widget → stats`

#### 1.2 레거시 (palette 숨김, alias 로 stats 에 흡수)

| 폴더 | 컴포넌트 ID | 현재 동작 | 분류 |
|---|---|---|---|
| `v2-aggregation-widget/` | `v2-aggregation-widget` | `POST /table-management/tables/{tableName}/data` 호출 + 클라이언트 사이드 집계, V2EventBus 구독 | 레거시 / G.5 폐기 후보 |
| `v2-status-count/` | `v2-status-count` | 같은 endpoint, statusColumn 기준 그룹 카운트 | 레거시 / G.5 폐기 후보 |
| `aggregation-widget/` | `aggregation-widget` | externalData prop 만, 자체 fetch 없음 | 레거시 / G.5 폐기 후보 |

DataPortBus 가 아니라 V2EventBus 위에서 동작. G.1 에서 재사용은 **개념 (filter / aggregation type)** 만, 코드는 canonical 측에서 새로 작성하는 게 깨끗함.

### 2. Tabs / Container / Section

#### 2.1 canonical `container` (★ G.2 의 진입점)

- 폴더: `frontend/lib/registry/components/container/`
  - `ContainerComponent.tsx` — `containerType` 5 가지 (tabs / section / accordion / repeater / conditional)
  - `InvContainerConfigPanel.tsx` — cp 시스템
  - `types.ts` — `ContainerConfig`, `ContainerTab { id, label }`
  - `index.ts`
- 현재 상태: **모든 모드 스켈레톤**. tabs 모드의 컨텐츠 영역은 literal `"[{activeTab}] 탭 컨텐츠 영역"` 텍스트만 출력. 자식 컴포넌트 렌더 없음
- `TemplateComponent.children?: TemplateComponent[]` — `frontend/types/invyone-component.ts:856` 에 선언만 되어 있고 어디서도 사용 안 됨
- state: 로컬 `useState(activeTab)`. 외부 통제 없음. 탭 별 데이터 소스 개념 없음

#### 2.2 레거시 (palette 숨김)

| 폴더 | 컴포넌트 ID | 비고 |
|---|---|---|
| `tabs/` | `tabs` | label 만 가진 단순 탭 |
| `v2-tabs-widget/` | `v2-tabs-widget` | 탭 안에 inline 컴포넌트 배치 (디자인 타임만) |
| `accordion-basic/` | `accordion-basic` | 미완성 |
| `section-card/` `section-paper/` `v2-section-card/` `v2-section-paper/` | section 변형 | container 가 흡수 |
| `split-panel-layout/` `split-panel-layout2/` `v2-split-panel-layout/` | split-panel-layout | **★ 패널 별 tableName / dataFilter / additionalTabs 필드 보유** — G.2 의 per-tab 데이터 모델 레퍼런스 |
| `repeat-container/` `v2-repeat-container/` | repeat | container repeater 가 흡수 |
| `conditional-container/` | conditional | container conditional 이 흡수 |

`v2-split-panel-layout` 의 `additionalTabs: AdditionalTabConfig[]` 가 "탭 마다 독립 tableName / filter / columns" 패턴을 이미 가지고 있음. 이 구조를 canonical container.tabs 로 옮기는 게 G.2 핵심.

#### 2.3 ComponentType union 현황

`frontend/types/invyone-component.ts:171-182`:

```
'table' | 'form' | 'search' | 'button' | 'button-bar' | 'tabs'
 | 'split-panel' | 'title' | 'stats' | 'divider' | 'pagination'
```

`tabs / split-panel / button-bar / pagination` 은 model-only (palette 숨김). 새 "tabbed data view" 를 별도 ComponentType 으로 추가하지 않고 **canonical `container` 의 `containerType: "tabs"` 를 확장** 하는 게 자연스러움.

`container` 자체는 `ComponentType` union 에 없음 (string literal 11 종 외). 등록은 ComponentRegistry 측에서. 신규 union 추가는 G.2 단계에서 결정.

### 3. Table

- 폴더: `frontend/lib/registry/components/table/`
  - `TableComponent.tsx` — 5 display mode (table / split / grouped / pivot / card)
  - `InvTableConfigPanel.tsx`
  - `useTableData.ts` — pagination / sort / search state hook
  - `internals/pivot/` — `PivotChart.tsx` 포함
- ComponentType: `"table"`
- 데이터 fetch: `entityJoinApi.getTableDataWithJoins(tableName, { page, size, sortBy, sortOrder, search, enableEntityJoin, screenEntityConfigs, dataFilter, excludeFilter })`
  - response: `{ data: Record<string, any>[], total, page, size, totalPages, entityJoinInfo? }`
  - backend: `POST /table-management/tables/{tableName}/data`
- alias: `v2-table-list → table`, 그 외 다수 (`split-panel-layout`, `modal-repeater-table` 등)
- G.1 / G.2 에서 신규 작업 없음. 기존 `useTableData` + `entityJoinApi` 재사용.

### 4. Chart

#### 4.1 라이브러리

- `recharts ^3.2.1` (`frontend/package.json`)

#### 4.2 현재 차트 구현

| 위치 | 용도 | 상태 |
|---|---|---|
| `frontend/lib/registry/components/ChartRenderer.tsx` | studio canvas 차트 컴포넌트 | **placeholder 아이콘만**. legacy `componentRegistry.register("chart")` / `register("chart-basic")` 로만 등록. 실제 차트 렌더 안 함. recharts 사용 X |
| `frontend/components/admin/dashboard/charts/` | admin dashboard 전용 차트 | **실제 recharts 차트**. BarChart / PieChart / LineChart / AreaChart / ComboChart / HorizontalBarChart / StackedBarChart 풀세트. 단, 사용처가 `CanvasElement.tsx`, `DashboardViewer.tsx` (admin 대시보드 시스템) 로 studio runtime 과 완전 별개 |
| `frontend/lib/registry/components/table/internals/pivot/components/PivotChart.tsx` | pivot 테이블 모드 내장 차트 | **실제 recharts 차트** (`PivotBarChart`, `PivotLineChart`, `PivotPieChart`). pivot view 한정 |

#### 4.3 결론

studio canvas 위에 **운영 가능한 차트 컴포넌트는 사실상 없음** (`ChartRenderer.tsx` 는 placeholder). G.3 에서 canonical 차트 컴포넌트를 만들 때:

- recharts 는 이미 들어와 있음 — 새 dependency 추가 X
- admin dashboard 의 차트 코드 (`BarChart.tsx`, `PieChart.tsx` 등) 가 reference. 다만 admin dashboard 의 `DashboardElement` 모델과 결합되어 있어서 그대로 가져올 수는 없음. studio canonical 시그니처 (data: rows → render) 로 새로 래핑
- pivot 내부 `PivotChart.tsx` 는 더 가깝게 재사용 가능 (이미 recharts 직접 호출)

### 5. 데이터 fetching API

| API 클라이언트 | 위치 | 주요 함수 | response 형태 | 사용처 |
|---|---|---|---|---|
| `entityJoinApi` | `frontend/lib/api/entityJoin.ts` | `getTableDataWithJoins(tableName, opts)` | `{ data, total, page, size, totalPages, entityJoinInfo? }` | canonical `table`, `v2-table-list` |
| `dataApi` | `frontend/lib/api/data.ts` | `getTableData(tableName, opts)`, `getRecordDetail(...)` | `{ data, total, page, size, totalPages }` | `aggregation-widget`, legacy 컴포넌트 |
| `tableTypeApi` | `frontend/lib/api/screen.ts` (line 526+) | `getTables()`, `getTableLabel(...)`, `getColumns(...)`, `getTableData(...)` | 메타 + auto-filter (company_code) | config panel, schema loader |

G.1 (Data-bound Stats) 는 같은 endpoint 위에 **count / aggregate 변형**이 필요. 두 가지 옵션:

- A. backend 에 `POST /table-management/tables/{tableName}/aggregate` 신규 추가 (column / aggregation / filters 받음)
- B. 기존 `POST /.../data` 로 가져와 클라이언트 사이드 count — 데이터량 많으면 비효율, v2-aggregation-widget 이 `size: 10000` 으로 이걸 하고 있음

권장: 옵션 A. 단, 백엔드 작업이 들어가므로 G.1 1순위 spike 에서 결정.

### 6. Filter Resolver

- 위치: `frontend/lib/registry/components/input/use-option-loader.ts`
- 타입:

  ```ts
  interface OptionFilter {
    column: string;
    operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
    value_type?: "static" | "field" | "user";
    value?: unknown;
    field_ref?: string;
    user_field?: "companyCode" | "userId" | "deptCode" | "userName";
  }
  ```

- 현재 사용처: canonical `input` (entity / distinct / db source 의 옵션 로딩)
- G.1 에서 그대로 재사용. 신규 stats card 의 `filters` 가 같은 모델로 동작하면 변환 코드 0.
- G.2 의 탭 별 filter 도 동일 모델 사용 가능.

### 7. DataPort 인프라

- 위치: `frontend/lib/dataPort/`
  - `DataPortBus.ts` — pub/sub 버스. 채널 = `${componentId}.${portName}`. 화면 단위 인스턴스 + `defaultDataPortBus` 전역 인스턴스 제공
  - `runtime.ts` — `setupConnections(connections, bus)`
- 선언된 컴포넌트:
  - `stats` 의 `index.ts` 에 `dataPorts.inputs: [{ name: "data", type: "rows" }]` 선언됨
  - 다른 canonical (table / input / button 등) 은 `dataPorts` 미선언 또는 부분 선언
- 활성화 상태: **renderer 측 wiring 미완**. `BlockRenderer` / `TemplateRenderer` 어디서도 `setupConnections` 를 호출 안 함. publish / subscribe 호출 0건. `Template.connections` 가 항상 빈 배열로 저장됨
- 결론: G.1 stats 의 "데이터 흐름" 은 DataPortBus 를 정식 wiring 하지 말고, **props / 직접 fetching** 으로 먼저 작동시키는 게 빠르고 안전. DataPort 활성화는 별도 phase (Phase 3 노드 에디터 도입과 함께) 로 미룸.

### 8. Registry / Rendering 파이프라인

- `Component { type, webType, config }` 이 들어오는 진입점: `frontend/lib/registry/DynamicComponentRenderer.tsx`
- 흐름:
  1. componentType 추출 (`component.componentType` → `component.type` fallback)
  2. `LEGACY_TO_UNIFIED` alias 매핑 (`v2-table-list → table`, `v2-aggregation-widget → stats`, …)
  3. `ComponentRegistry.getComponent(componentType)` 로 ComponentDefinition 조회
  4. 정의의 `component` 를 React 렌더
- ConfigPanel 매핑: `frontend/lib/utils/getComponentConfigPanel.tsx`
  - `CONFIG_PANEL_MAP` (~100 entries) + `CONFIG_PANEL_ALIAS` (legacy 매핑)
  - 사용처: `V2PropertiesPanel.tsx` 의 `DynamicComponentConfigPanel`
- webTypeMapping: `frontend/lib/utils/webTypeMapping.ts` — FieldConfig.type / FieldConfig.format 조합 → `{ componentType, config }`. canonical `input` 이 ~40 종 webType 을 흡수
- 신규 컴포넌트 추가 시 touch 파일 (canonical `input` 패턴 따라하기):
  1. `frontend/lib/registry/components/<new-comp>/` 폴더 + Renderer + ConfigPanel + Component + index.ts + types.ts
  2. `frontend/lib/registry/components/index.ts` 에 `import "./<new-comp>/<New>Renderer";` 추가
  3. `frontend/lib/utils/getComponentConfigPanel.tsx` 의 `CONFIG_PANEL_MAP` 에 entry
  4. (palette 노출 시) `frontend/components/screen/panels/ComponentsPanel.tsx` 의 `BASIC_IDS` 또는 `ADVANCED_IDS`
  5. (필요 시) `frontend/types/invyone-component.ts` 의 `ComponentType` union 에 추가
  6. (필요 시) `frontend/lib/utils/webTypeMapping.ts` entry

G.1 / G.2 는 신규 컴포넌트 폴더가 아니라 기존 canonical (`stats`, `container`) 의 **runtime 확장**이라 touch 가 적음.

### 9. Stale V2 wording

확인 결과, runtime 영역의 V2 표현은 **현재 진행 중인 통합 전략의 흔적**이며 stale 이 아님:

- `frontend/lib/registry/components/index.ts:77` — `// V2 컴포넌트들 (...)` 주석. 흡수 진행 중 표시
- `getComponentConfigPanel.tsx:133` — `// ── Phase E: v2-* → 통합 컴포넌트 ConfigPanel alias ──` — 의도된 alias 블록 표시
- `DynamicComponentRenderer.tsx:365, 375` — Phase E 주석. 이력 의도
- `ComponentsPanel.tsx:99-160` — hidden list 의 `// → canonical input` 화살표 주석. 의도된 migration map
- `V2PropertiesPanel.tsx:225-236` — `const v2ConfigPanels` 하드코딩 매핑 (`v2-layout`, `v2-bom-tree` 등 미마이그된 도메인 패널)

→ **이번 phase 에서 코드 수정 없음.** 어느 것도 단순 주석 cleanup 으로 끝나지 않고, 실제 마이그레이션 phase 와 묶여서 함께 정리해야 함.

추가로 폴더 이름 자체 (`frontend/components/v2/`) 도 컨벤션상 마이그레이션 끝난 후 `inv/` 로 일괄 rename 예정 — 이건 별도 phase. G.0 에서 건드리지 않음.

### 10. 재사용 가능 / 폐기 대상 정리

#### 재사용 (G.1 / G.2 에서 의존)

- `stats` 컴포넌트 (canonical) — items renderer + style/orientation 변형 + cp 컨피그 패널
- `container` 컴포넌트 (canonical) — tabs 모드의 헤더 + active 상태 — body 만 children renderer 로 교체
- `entityJoinApi` / `dataApi` — 데이터 fetch
- `useTableData` — pagination/sort/search 상태 관리 패턴
- `OptionFilter` + `useOptionLoader` filter 모델 — 사용자 컨텍스트 (companyCode 등) placeholder 치환
- `InvTableConfigPanel` 의 filter / column / dataFilter UI 패턴 — G.2 탭 별 데이터 소스 설정에 차용
- `v2-split-panel-layout` 의 `additionalTabs` 타입 모양 — G.2 `ContainerTab` 확장 시 reference (코드 복사 아님)
- `PivotChart.tsx` (recharts 사용 패턴) — G.3 canonical 차트 시 reference
- admin dashboard charts (`frontend/components/admin/dashboard/charts/`) — G.3 reference (직접 import X)
- `DataPortBus` — 향후 wiring 시점에 사용. G.1 / G.2 에서는 직접 props 우선

#### 향후 폐기 (G.5)

- `v2-aggregation-widget/` (alias → stats)
- `v2-status-count/` (alias → stats)
- `aggregation-widget/` (alias → stats)
- `tabs/` (legacy)
- `v2-tabs-widget/` (alias → container)
- `accordion-basic/` (legacy 미완)
- `section-card/`, `section-paper/`, `v2-section-card/`, `v2-section-paper/` (alias → container)
- `split-panel-layout/`, `split-panel-layout2/`, `v2-split-panel-layout/` (alias → table / container)
- `repeat-container/`, `v2-repeat-container/` (alias → container)
- `conditional-container/` (alias → container)
- `frontend/lib/registry/components/ChartRenderer.tsx` (placeholder; G.3 canonical 차트가 들어오면 삭제)

각 폐기는 alias가 비어 있고 DB 에 저장된 컴포넌트 ID 사용처가 0건임을 확인한 후 별도 plan/phase 로 진행.

### 11. Phase G.1 Data-bound Stats 추천 플랜

**원칙**: canonical `stats` 만 건드림. 새 컴포넌트 추가 X. DataPort wiring 도 미루고 직접 fetch.

1. **데이터 모델 확장**
   - `frontend/lib/registry/components/stats/types.ts` 에 `StatItemDataSource` 추가
     - `tableName: string`
     - `aggregation: "count" | "sum" | "avg" | "min" | "max" | "distinctCount"`
     - `columnName?: string`
     - `filters?: OptionFilter[]` — 기존 `OptionFilter` 그대로 재사용
   - `StatsItem` 에 `dataSource?: StatItemDataSource` 필드 추가 (기존 static `value` 와 양립)

2. **runtime 데이터 로더**
   - `frontend/lib/registry/components/stats/useStatsData.ts` (신규) — `items: StatsItem[]` → `Record<string, number>` 결과 맵 반환
   - 디자인 모드: skeleton / "—" / 0 표시. API 호출 0건
   - 운영 모드: 같은 tableName 끼리 묶어서 단일 요청 또는 in-flight 캐시
   - 실패 시 카드 단위 fallback (전체 화면 무너지지 않음)

3. **백엔드 endpoint** (★ 결정 필요)
   - 옵션 A 권장: `POST /table-management/tables/{tableName}/aggregate` 신설
     - body: `{ aggregation, columnName?, filters: OptionFilter[] }`
     - response: `{ value: number }` (count 면 정수, sum/avg 면 숫자)
   - 옵션 B fallback: 기존 `/data` endpoint 에서 size 1 + totalCount 만 사용 (count 한정)
   - 옵션 결정 전에 backend 측 spike 1회 필요 (별도 task)

4. **filter resolver**
   - `OptionFilter[]` 를 그대로 보내고 백엔드가 처리하는 방향 권장. 클라이언트는 `value_type === "field"` 또는 `"user"` 를 fieldRef / userContext 에서 치환만 함
   - 신규 placeholder 추가 없음

5. **InvStatsConfigPanel 확장**
   - 기존 cp 시스템 그대로
   - 각 item 에 "데이터 소스" 그룹 추가: 테이블 / 집계 / 컬럼 / 필터
   - 필터는 `OptionFilter` 빌더 — `InvTableConfigPanel` 의 dataFilter 빌더 그대로 차용 (또는 cp `FilterBuilder` 신규)
   - static value 와 dataSource 가 둘 다 있으면 dataSource 우선

6. **검증 기준 (roadmap §4 G.1)**
   - `user_info` 전체 count 카드 한 개 만들기
   - `status = '재직'` count 카드 만들기
   - 필터 column 을 다른 값으로 바꾸면 값 갱신
   - 잘못된 tableName → 카드 한 장만 에러 표시, 나머지 카드 정상

7. **하드 제약**
   - 인사 / 부서 / 사원 키워드를 컴포넌트 코드에 박지 말 것. 컬럼 / 테이블 이름 전부 config 로
   - `v2-input`, `v2-select`, `EntityPicker` 다시 도입 금지
   - DB layout JSON migration SQL 작성 금지
   - `FieldConfig` 축소 / 재설계 금지

8. **추정 작업량**: 백엔드 endpoint + 프론트 hook + 컨피그 패널 확장. 백엔드 spike 1회 포함하여 1.5~2 일 정도.

### 12. Phase G.2 Tabbed Data View 추천 플랜

**원칙**: canonical `container` 의 `containerType: "tabs"` 만 건드림. 새 ComponentType 추가 X.

1. **타입 확장**
   - `frontend/lib/registry/components/container/types.ts` 의 `ContainerTab` 에 추가:
     - `dataSources?: DataSourceConfig[]`
     - `filters?: FilterConfig[]` (FieldConfig 와 별개. 검색 필터용 — `FieldConfig` 재사용도 검토)
     - `components?: TemplateComponent[]` — 탭 내부 배치 컴포넌트들
     - `viewType?: "table" | "split-list-chart" | "grouped-table" | "custom"` (선택)
   - `TemplateComponent.children` (이미 선언됨) 의 의미를 "탭 내부 컴포넌트들" 로 확정할지 또는 `ContainerTab.components` 를 별도 사용할지 결정 필요. 추천: `ContainerTab.components` (탭 별 격리가 더 명시적)

2. **runtime 자식 렌더**
   - `ContainerComponent.tsx` 의 `renderTabs()` body 부분의 placeholder 텍스트 제거
   - 현재 active tab 의 `tab.components: TemplateComponent[]` 를 `DynamicComponentRenderer` 로 매핑하여 렌더
   - 디자인 모드: drop zone 표시. 자식 컴포넌트 배치 가능
   - 운영 모드: 직접 렌더

3. **탭 별 스코프**
   - 현 단계는 단순히 props 로 데이터 흐르게 두기 (DataPort 활성화 미룸)
   - 탭 별 `dataSources` 가 있으면 탭 컨테이너 레벨에서 fetch 후 자식에 prop drilling
   - 탭 전환 시 비활성 탭 렌더 OFF (lazy) — 데이터 fetch 도 OFF

4. **active tab 관리**
   - 기존 `useState(activeTab)` 유지
   - `defaultTab` config 존중
   - URL query / DataPort 통제는 G.2 범위 밖

5. **InvContainerConfigPanel 확장**
   - `containerType === "tabs"` 일 때 각 탭 별로 펼침 가능한 cp 그룹:
     - 탭 메타 (id / label / icon)
     - 데이터 소스 빌더 (선택)
     - 자식 컴포넌트 리스트 (선택. 빌더 캔버스에서 드래그하면 자동 채워지므로 패널은 ID 표시만)

6. **검증 기준 (roadmap §4 G.2)**
   - 한 화면에 탭 3개. 각 탭 안에 서로 다른 컴포넌트 (테이블, 카드 목록, 차트 placeholder) 배치
   - 탭 전환 시 그 탭의 컴포넌트만 표시
   - 탭 설정 간 누수 없음 (탭 A 의 filter 가 탭 B 에 영향 X)

7. **하드 제약**
   - 인사 관리 전용 if 분기 금지
   - 옛 `v2-tabs-widget` / `tabs` / `accordion-basic` 부활 금지
   - `Template.connections` 강제 활성화 금지 (별도 phase)
   - `FieldConfig` / `DataPort` 축소 금지
   - 하드코딩된 탭 라벨 ("사원 목록" 등) 금지

8. **추정 작업량**: 타입 확장 + 자식 렌더 + 컨피그 패널 + 디자인 모드 drop zone. 2~3 일.

### 13. 다음 단계 추천

- 즉시 시작 가능: **Phase G.1** (백엔드 spike 1회 후 본격 구현)
- G.2 는 G.1 끝나고 시작. 두 phase 가 서로 독립이라 병렬도 가능하나 단일 작업자면 직렬이 안전
- G.3 (차트 / 카드 목록 / grouped table) 은 G.2 끝나고. recharts 기반 신규 canonical 컴포넌트 신설
- G.4 (인사 관리 화면 검증) 는 G.3 끝나고. 화면 자체는 빌더 설정만으로 생성
- G.5 (legacy cleanup) 는 가장 마지막. 메모리에 박힌 alias / 폐기 후보 리스트 그대로 진행

코드 변경 0건. 본 인벤토리 작업으로 추가된 파일 외에 수정 없음.

---

### Phase G.1 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### Changed files

Backend (Java / Spring):
- `backend-spring/src/main/java/com/erp/controller/TableManagementController.java`
  - 신규 endpoint `POST /api/table-management/tables/{tableName}/aggregate`
- `backend-spring/src/main/java/com/erp/service/TableManagementService.java`
  - 신규 메서드 `aggregateTableData(String tableName, Map options)` + 내부 헬퍼 (`buildAggregateWhere`, `normalizeAggregateFilters`, `toList`, 상수 `AGG_TYPES`, `FILTER_OPS`)

Frontend (Next / TS):
- `frontend/lib/api/stats.ts` (신규) — `aggregateTableStat(tableName, request)` + `AggregateRequest` / `AggregateResponse` 타입
- `frontend/lib/registry/components/stats/types.ts` — `StatsAggregation` 에 `distinctCount` 추가, `StatItemDataSource` 신규, `StatsItem.id?`, `StatsItem.dataSource?` 신규
- `frontend/lib/registry/components/stats/use-stats-data.ts` (신규) — runtime 데이터 로더 hook
- `frontend/lib/registry/components/stats/StatsComponent.tsx` — `useStatsData` 호출 + 항목 단위 `displayValue` 결정 (dataSource fetch 값 우선, fallback `item.value`)
- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 항목 expand 영역에 `DataSourceEditor` (테이블 / 집계 / 컬럼 / 필터) + `FilterRow` 추가

#### Backend endpoint contract

```
POST /api/table-management/tables/{tableName}/aggregate
Content-Type: application/json

{
  "aggregation": "count" | "sum" | "avg" | "min" | "max" | "distinctCount",
  "columnName": "optional_column",
  "filters": [
    { "column": "status", "operator": "=", "value": "재직" }
  ]
}
```

응답 (ApiResponse wrapper):
```
{
  "success": true,
  "data": { "value": 12 },
  "message": "테이블 집계를 성공적으로 조회했습니다."
}
```

#### Supported aggregation / filter operators

| aggregation | columnName | 비고 |
|---|---|---|
| `count` | optional | column 없으면 `COUNT(*)`, 있으면 `COUNT("col")` (NULL 제외) |
| `distinctCount` | required | `COUNT(DISTINCT "col")` |
| `sum`, `avg` | required | `CAST(col AS NUMERIC)` 후 집계. 숫자 호환 값이 들어 있는 컬럼에서 사용 |
| `min`, `max` | required | 컬럼 타입 그대로 |

| operator | 처리 |
|---|---|
| `=`, `!=`, `>`, `<`, `>=`, `<=` | 단일 `?` 파라미터 |
| `like` | `"col"::text ILIKE '%val%'` |
| `in`, `notIn` | 배열 / 콤마 문자열 → `IN (?, ?, ...)` |
| `isNull`, `isNotNull` | 값 없이 적용 |

검증:
- 모든 식별자는 기존 `sanitize` (영문/숫자/_ 외 제거) 후 `hasColumn` 으로 컬럼 존재 확인. 없는 컬럼은 자동 drop
- 잘못된 aggregation / 누락된 column / 없는 테이블 → `IllegalArgumentException` → 400 에러
- 값은 `JdbcTemplate` 의 `?` 파라미터 바인딩만 사용. 식별자 자리에 raw input 들어가지 않음
- `filters` 가 배열이 아니거나 배열 안 원소가 object 가 아니면 무시. 잘못된 filter payload 로 500 이 나지 않게 정규화

#### Frontend filter resolver

`OptionFilter.value_type` (canonical):
- `static` — `value` 그대로 전송
- `field` — `field_ref` 가 `formData` 에 있어야 함. 없으면 해당 항목 fetch skip (카드는 idle 상태)
- `user` — `user_field` 가 `userContext` 에 있어야 함. 없으면 skip

이렇게 해서 백엔드는 `column / operator / value` 만 받는 단순 모델 유지. `value_type` resolution 은 프론트에서 완료.

#### 디자인 모드 / 캐싱 / race guard

- `isDesignMode === true` → API 호출 0. 정적 `item.value` 표시
- request key 캐시 (`buildRequestKey`): `tableName + aggregation + columnName + resolved filters` 의 stringify. 같으면 재호출 안 함
- version 카운터로 stale response 무시 (effect 재실행 시 이전 버전 결과 drop)
- 카드 단위 try/catch — 한 카드 실패 시 다른 카드 영향 0. 실패 카드는 "—" 표시 + `state[key].error`
- 정적 stats / 디자인 모드 / fetch 대상 0건인 경우 `setState({})` 반복으로 렌더 루프가 생기지 않도록 plan signature 와 state 변경 여부를 체크
- `InvStatsConfigPanel` 의 테이블 목록은 상위 패널에서 1회 `useDbTables()` 호출 후 각 item editor 로 전달. 항목 수만큼 테이블 목록 API 를 중복 호출하지 않음

#### Remaining limitations

1. **컬럼 선택 UI** — 현재 컬럼명은 free text 입력. 테이블에 따라 컬럼 자동 완성 / dropdown 제공은 후속 phase 에서 (`tableManagementApi.getColumnList` 재사용 예정)
2. **숫자 포맷팅** — `StatsItem.format` 필드는 type 에만 있고 hook 결과 표시는 `Number.toLocaleString` 기본만. `#,##0.00` 등 커스텀 포맷 적용은 별도 작업
3. **자동 갱신** — 현재는 mount 시 1회 + `refreshKey` 변경 시 재조회. polling / 주기적 refresh 옵션은 미구현
4. **다중 테이블 배치 요청** — 같은 tableName 끼리 묶어서 1회 요청으로 처리하는 최적화는 미구현 (현재는 항목별 1요청). 카드 수가 적은 KPI 화면에서는 문제 없음
5. **`value_type: "user"` 컨텍스트** — `StatsComponent` props 의 `companyCode / userId / deptCode / userName` 만 인식. 외부 wrapper 가 명시적으로 props 로 전달해야 함. 빌더 / 런타임 wiring 은 기존 패턴 그대로
6. **DataPort 활성화 미함** — Phase G.0 결정대로 props / hook 으로만 동작. `DataPortBus` wiring 은 별도 phase

#### Verification

```bash
git diff --check                                           PASS
git diff --cached --check                                  PASS
cd backend-spring && ./gradlew compileJava                BUILD SUCCESSFUL in 10s
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer"   0 matches
rg "EntityPicker|entity-picker|EntitySearchModal"          0 matches (input + InvFieldConfigPanel)
rg 'componentType: "v2-file-upload"|file: ...'             0 matches
```

전체 `tsc` 는 기존 전역 camelCase/snake_case 에러로 실패하는 게 정상. G.1 변경 파일 한정 신규 에러 0 건 확인 (frontend 측 별도 ts 모듈 추가 + 기존 모듈 수정 모두 자체 컴파일 가능 — 의존하는 export 들은 그대로 유지).

#### 다음 권장 단계

- **Phase G.2 Tabbed Data View** 진입. canonical `container` (`containerType: "tabs"`) 의 placeholder body 를 자식 컴포넌트 렌더로 교체. 새 ComponentType 추가 없이 `ContainerTab` 에 `components / dataSources / filters` 확장
- G.1 마무리 후속:
  - 컬럼 dropdown (테이블 변경 시 `getColumnList` 로 옵션 채움)
  - `StatsItem.format` → 표시 포맷 적용
  - 같은 테이블 묶음 요청 최적화 (필요 시)

---

### Phase G.2 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### Changed files

Frontend (Next / TS):
- `frontend/lib/registry/components/container/types.ts` — `ContainerChildComponent` 신규, `ContainerTab` 에 `icon? / components? / dataSources? / filters? / viewType?` 추가
- `frontend/lib/registry/components/container/ContainerComponent.tsx` — `containerType === "tabs"` 의 placeholder body 제거. 활성 탭의 `components` 를 `DynamicComponentRenderer` 로 렌더. `ChildSlot` 헬퍼 + drop-zone fallback 추가. props 전달 (`formData / userId / userName / companyCode / refreshKey` 등)
- `frontend/lib/registry/components/container/InvContainerConfigPanel.tsx` — 탭 row 에 `(N개)` 자식 개수 뱃지 추가 (minimal). 라벨 편집 / 추가 / 삭제 / 기본 탭 선택은 기존 그대로

Backend (Java/Spring): 변경 없음.
Documentation: 본 섹션 추가.

#### Runtime behavior added

- **활성 탭만 mount**: `tabs.find(t => t.id === activeTab)` 의 `components` 만 렌더. 비활성 탭은 React tree 에 진입조차 안 함 (성능 + side-effect 격리). 탭 전환 시 이전 탭 unmount.
- **빈 탭 fallback**:
  - 디자인 모드: dashed border + "이 탭이 비어 있습니다. 좌측 팔레트에서 컴포넌트를 끌어다 놓으세요." 안내 (drop-zone-like). 실제 drop wiring 은 외부 빌더 책임이라 안내 텍스트만
  - 운영 모드: `null` 반환 (빈 영역)
- **자식 정규화**: `ContainerChildComponent.{ id, componentType, componentConfig, size }` 를 `DynamicComponentRenderer` 가 받는 `component { id, componentType, type, componentConfig, component_type, component_config, size }` 형태로 매핑. camelCase / snake_case 둘 다 채워서 renderer 측 fallback 매칭 100% 호환
- **props 전파**: `formData / onFormDataChange / userId / userName / companyCode / screenId / menuId / menuObjid / tableName / selectedRows / selectedRowsData / onSelectedRowsChange / refreshKey / onRefresh / isInModal` 를 자식에 그대로 forward. DataPortBus wiring 은 활성화하지 않음 (G.0 결정)
- **자유 배치**: `size.width / height` 가 있으면 자식 wrap div 에 inline style 로 적용. 좌표 (position) 는 G.2 범위 밖 — 외부 빌더 / 차후 phase 책임
- **탭 헤더**: 기존 UI (가로 button 라인, 활성 탭 primary color 보더) 그대로. `tab.icon` 이 있으면 라벨 앞에 표시 (이모지 / 짧은 문자열). 클릭 시 디자인 모드에서도 미리보기 전환 가능 (이전엔 디자인 모드에서 setActiveTab 막혀 있었음)

#### Codex verification correction

검증 중 확인된 작은 런타임 보정:
- `activeTab` 이 삭제되었거나 현재 `tabs` 목록에 없을 때 본문은 첫 탭으로 fallback 되는데, 헤더 active 표시는 stale `activeTab` 을 보던 문제를 `activeTabId = currentTab?.id` 기준으로 맞춤
- 탭 내부 자식에게 `parentTabId / parentTabsComponentId` 를 전달해서 차후 빌더 선택 / 위치 업데이트 wiring 이 부모 탭을 식별할 수 있게 함
- `ChildSlot` 주석에서 실제 구현되지 않은 try/catch boundary 언급 제거

#### Config panel changes

- 탭 list 의 각 row 에 `(N개)` / `·` 미니 뱃지 추가. mono font, muted color. tooltip = "이 탭에 배치된 컴포넌트 수"
- 추가 / 삭제 / 라벨 편집 / 기본 탭 선택은 기존 그대로 유지 — 패널 재설계 안 함
- 자식 컴포넌트의 실제 배치 / 편집은 캔버스 빌더 책임. config 패널은 메타 표시만

#### Type model (요약)

```ts
export interface ContainerChildComponent {
  id: string;
  componentType: string;          // "table" | "stats" | "search" | "button" | "input" | ...
  componentConfig?: Record<string, any>;
  label?: string;
  size?: { width?: number | string; height?: number | string };
}

export interface ContainerTab {
  id: string;
  label: string;
  icon?: string;
  components?: ContainerChildComponent[];
  dataSources?: unknown[];        // forward compat (runtime 미사용)
  filters?: unknown[];            // forward compat (runtime 미사용)
  viewType?: string;              // forward compat (runtime 미사용)
}
```

`TemplateComponent` (`frontend/types/invyone-component.ts:822`) 보다 의도적으로 작음. order / row / responsive / parentId / groupChildren / inputs / outputs 등은 G.2 범위 밖 — 빌더 자유 배치 또는 DataPort 활성화 phase 영역. 차후 phase 에서 `ContainerChildComponent` 가 `TemplateComponent` 의 일부 필드를 흡수하는 식으로 확장 가능.

#### Verification

```bash
git diff --check                                                                          PASS
git diff --cached --check                                                                 PASS
rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/...                     0 matches
rg "EntityPicker|entity-picker|EntitySearchModal" input + InvFieldConfigPanel             0 matches
rg "v2-tabs-widget|componentType: \"tabs\"|id: \"tabs\"" frontend/...                     pre-existing only
tsc --noEmit --pretty false (filter container/ paths)                                     0 new errors
```

전체 `tsc` 총 에러 수 3376 — Phase G.1 종료 시점과 동일 (G.2 가 신규 에러 도입 0). 기존 전역 camelCase / snake_case · BlockRole 에러만 남음.

`v2-tabs-widget` / `componentType: "tabs"` grep 결과 매치는 모두 G.2 작업 전부터 있던 alias / 마이그레이션 / 흡수 주석 / 레거시 폴더 코드 — G.2 신규 도입 0건. 주요 위치:
- `frontend/lib/registry/components/container/types.ts` — 흡수 대상 주석 (`v2-tabs-widget (탭)`). 기존
- `frontend/lib/utils/getComponentConfigPanel.tsx` — alias `"v2-tabs-widget" → "container"`. 기존
- `frontend/lib/utils/templateMigrate.ts` — `'v2-tabs-widget': 'container'` 마이그레이션 매핑. 기존
- `frontend/lib/registry/DynamicComponentRenderer.tsx` — `LEGACY_TO_UNIFIED` 안의 alias. 기존
- `frontend/lib/registry/components/v2-tabs-widget/` 폴더 자체. 기존 (Phase G.5 Legacy Cleanup 대상)
- `frontend/lib/registry/components/tabs/tabs-component.tsx` — `hidden: true` 레거시 폴더. 기존 (Phase G.5 대상)
- `frontend/components/screen/ScreenDesigner.tsx`, `RealtimePreviewDynamic.tsx`, `InteractiveScreenViewer.tsx`, `SplitPanelLayoutComponent.tsx`, `ComponentsPanel.tsx`, `componentConfig.ts` 등 — 모두 알려진 alias / hidden list / migration 코드. 기존

#### Remaining limitations

1. **빌더 측 drag-drop 미연결** — `ChildSlot` 까지의 렌더 wiring 은 완료. 빌더가 캔버스에서 컴포넌트를 끌어다 놓아서 `tab.components` 에 추가하는 UI / store 업데이트는 외부 (ScreenDesigner 등) 책임. 현재는 데이터를 코드로 주거나 다른 화면에서 마이그된 경우만 표시
2. **section / accordion / repeater / conditional 모드** — G.2 는 `containerType === "tabs"` 만 활성. 나머지 4 모드는 여전히 스켈레톤 placeholder. 각 모드의 자식 렌더는 별도 phase
3. **탭별 dataSources / filters runtime** — `ContainerTab.dataSources / filters` 는 type 에 추가만 됨. runtime 에서 이를 fetch 해서 자식에 prop 으로 주입하는 wiring 은 G.2 범위 밖 (G.3 View Components 와 함께 진행 예정)
4. **자식의 자유 좌표 배치** — `size.width / height` 만 적용. `position.x / y` 같은 absolute 좌표는 미지원 — vertical stack 만 함. 빌더에서 자유 배치 요구가 강해지면 차후 `ContainerChildComponent.position` 추가
5. **`Template.connections` (DataPort) 활성화** — Phase G.0 결정대로 미활성. 자식들은 props 로만 데이터 전달받음. DataPortBus subscribe / publish 는 별도 phase
6. **탭별 lazy state 보존** — 현재는 탭 전환 시 이전 탭 자식이 완전 unmount. 자식 내부 상태 (테이블 페이지 / 검색어 등) 가 초기화됨. "hidden 으로만 숨김" 모드는 G.2 범위 밖. 필요 시 `keepMounted: boolean` 옵션 추가 가능

#### 다음 권장 단계

- **Phase G.3 View Components** — 탭 안에서 사용할 범용 뷰 컴포넌트 정리 (`dataTable / searchFilter / cardList / barChart / donutChart / groupedTable`). 차트는 `recharts ^3.2.1` 위에 canonical 컴포넌트 신설. `frontend/components/admin/dashboard/charts/` 를 reference 로 함 (직접 import X)
- 빌더 측 작업: 캔버스에서 컴포넌트를 컨테이너 탭에 drop 했을 때 `tab.components` 배열을 업데이트하는 wiring (`ScreenDesigner` 영역)
- 마이그레이션: 기존 `v2-tabs-widget` 의 `TabItem.components: TabInlineComponent[]` (snake_case `component_type / component_config`) 를 canonical `ContainerTab.components: ContainerChildComponent[]` (camelCase) 로 변환하는 adapter. `templateMigrate.ts` 에 한 줄 추가

---

### Phase G.2.5 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

G.2 가 끝낸 "활성 탭의 `components` 를 `DynamicComponentRenderer` 로 렌더" 위에, 빌더 사용자가 실제로 캔버스에서 **canonical container 의 탭 body 에 컴포넌트를 끌어다 놓고 / 선택하고 / 삭제** 할 수 있도록 wiring 한다. 새 component type / 새 store / 새 panel 추가 0건. 기존 v2-tabs-widget 빌더 인프라 (handleComponentDrop tab 분기, handleSelectTabComponent, onUpdateComponent 등) 를 그대로 재사용.

#### Changed files

Frontend (Next / TS):
- `frontend/lib/registry/components/container/ContainerComponent.tsx`
  - 탭 body 컨테이너에 `data-tabs-container="true"`, `data-container-kind="canonical"`, `data-component-id`, `data-active-tab-id` 추가
  - 탭 body 빈 영역 클릭 시 `onSelectTabComponent(activeTabId, "", null)` 호출 → 선택 해제
  - `renderTabChildren` 가 `p.onSelectTabComponent`, `p.selectedTabComponentId`, `p.onUpdateComponent` 를 받아 자식 선택 / 삭제에 사용
  - `ChildSlot` 확장: design 모드 hover dashed outline, selected 시 primary outline, ✕ 삭제 버튼 (hover + selected 시 노출). 자식 자체의 클릭은 `pointer-events: none` 으로 차단해서 ChildSlot 의 선택 click 만 받게 함
- `frontend/components/screen/ScreenDesigner.tsx`
  - `handleComponentDrop` 의 tabs 분기 (line ~3858) 가 `compType === "container" && containerType === "tabs"` 도 인식하도록 확장 (`isCanonicalTabs` 변수)
  - 그 외에는 한 줄도 안 건드림. 기존 흐름 (`updatedTabs / updatedTabsComponent` 생성, 중첩 split-panel 처리, `setLayout + saveToHistory`) 그대로 재사용
- Backend: 변경 없음
- Documentation: 본 섹션 추가

#### Tab drop 동작 흐름

1. 사용자가 좌측 palette 에서 컴포넌트 (예: `table`, `stats`, `search`, `button`, `input`) 를 드래그
2. canonical `container` 의 `containerType === "tabs"` 의 body (= `[data-tabs-container="true"]`) 위에 drop
3. `handleComponentDrop` 이 tabsContainer 를 closest 로 찾고 `data-component-id` 로 container 인스턴스, `data-active-tab-id` 로 활성 탭 id 를 얻음
4. layout.components 에서 container 인스턴스를 찾아 `componentType === "container"` + `componentConfig.containerType === "tabs"` 검증
5. 새 child 객체 생성 후 `componentConfig.tabs[activeTabIdx].components` 끝에 push
6. `setLayout` + `saveToHistory`
7. ContainerComponent 가 next render 에서 새 child 까지 `DynamicComponentRenderer` 로 렌더 → 화면에 즉시 등장

#### 새 자식 shape (드롭 시 생성)

```ts
{
  id: `tab_comp_${Date.now()}_${rand}`,
  componentType: <palette item id>,    // 예: "table", "stats", "search", "input"
  label: <palette item name>,
  position: { x, y },                  // ContainerChildComponent 는 사용 안 함. 향후 자유 배치 phase 용으로 보존
  size: { width, height },             // ChildSlot 의 wrap 박스 폭/높이로 적용
  componentConfig: <palette default>
}
```

`ContainerChildComponent` (Phase G.2 정의) 는 `id / componentType / componentConfig / label / size` 만 필수로 사용. drop 결과의 `position` 은 forward-compat 으로 남겨두지만 G.2.5 에서는 무시. 캔버스 자유 배치 단계는 별도 phase.

#### 자식 선택 / 삭제 동작

- **선택**: 디자인 모드에서 자식 ChildSlot 클릭 → ChildSlot 의 onClick 이 `e.stopPropagation()` + `onSelectTabComponent(tabId, child.id, child)` 호출. ScreenDesigner 의 `handleSelectTabComponent` 가 `selectedTabComponentInfo` state 를 세팅하고 일반 `selectedComponent` 는 null 로 리셋 (기존 v2-tabs-widget 과 같은 동작)
- **선택 식별**: `selectedTabComponentId` prop 으로 어떤 자식이 선택됐는지 ChildSlot 에 전달됨. 선택된 자식은 2px primary outline
- **삭제**: 선택 / hover 시 우측 상단에 ✕ 버튼 표시. 클릭하면 `onUpdateComponent({...container, componentConfig: { ...컨피그, tabs: [...새tabs] }})` 호출. ScreenDesigner 의 `onUpdateComponent` (line 8327) 가 layout.components 를 갱신 + history 저장
- **탭 body 빈 영역 클릭**: `onSelectTabComponent(tabId, "", null)` 으로 선택 해제 (기존 동작과 호환)

#### Codex verification correction

검증 중 확인된 작은 런타임 보정:
- 탭 body 직접 배경 클릭뿐 아니라 dashed 빈 탭 안내 영역을 클릭해도 자식 선택이 해제되도록 body click 조건을 완화
- ChildSlot 은 선택 click 에서 `stopPropagation()` 하므로 자식 선택 / 삭제와 선택 해제 동작은 충돌하지 않음

#### Pointer events 처리

디자인 모드에서 자식 (예: 테이블의 행 / 셀, 버튼) 의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을 가로채지 않도록, 자식 컴포넌트를 감싸는 inner div 에 `pointer-events: none` 적용. 운영 모드에서는 `pointer-events: auto` 기본값. 결과:

- 디자인 모드: 자식 어디를 클릭하든 ChildSlot 선택만 일어남
- 운영 모드: 자식 자체가 정상 동작 (테이블 클릭 / 버튼 클릭 등)

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches |
| `rg "componentType: \"v2-file-upload\"\|..."` | 0 matches |
| `tsc --noEmit` G.2.5 변경 파일 (`ContainerComponent.tsx`, `ScreenDesigner.tsx` 의 ~line 3858 영역) | 0 new errors |
| `tsc --noEmit` 전체 | 3376 (G.1/G.2 종료 시점과 동일 — G.2.5 신규 0) |

ScreenDesigner.tsx 의 기존 camelCase / snake_case · LayoutData / ComponentData 미스매치 에러들은 모두 G.2.5 작업 전부터 있던 것. 본 phase 가 introduce 한 것 없음.

#### Remaining limitations

1. **자식 속성 편집 UI** — 좌측 패널 (V2PropertiesPanel) 은 현재 `selectedComponent` 만 본다. 자식 선택 시 `selectedTabComponentInfo` 가 세팅되지만 우측 props panel 은 "캔버스에서 블록을 선택하세요" 로 비어 보임. **임시 운영 방법**:
   - 자식 컴포넌트 자체의 props 를 바꾸려면 일단 자식 컨테이너 탭에서 ✕ 로 삭제 후 새로 drop 하거나
   - 컨테이너를 클릭해 컨테이너 자체의 InvContainerConfigPanel 에서 탭 라벨 / 자식 개수 확인
   - 향후 phase: V2PropertiesPanel 우측 분기에 `selectedTabComponentInfo` 케이스 추가 (기존 `{false && ...}` 로 비활성화된 좌측 panel 블록 (line ~7221) 의 패턴 그대로 우측에 이전). 별도 단일 phase 로 처리 권장
2. **자식 reorder UI** — 자유 순서 변경 미지원. `tab.components` 의 push 순서 그대로 위→아래 vertical stack. 향후 드래그 reorder 또는 ▲▼ 버튼 추가 가능
3. **자유 좌표 (position) 배치** — drop 좌표는 `position` 에 저장만 되고 ChildSlot 은 vertical stack 만. position 기반 자유 배치는 별도 phase
4. **column / 테이블 컬럼 드래그 → 탭** — `handleDrop` 의 column 분기 (line ~4359) 는 아직 canonical container 의 탭 분기를 인식 안 함. 컬럼 drop 으로 자동 input 컴포넌트 생성은 별도 작업 (v2-tabs-widget 만 현재 지원)
5. **section / accordion / repeater / conditional 모드** — drop wiring 은 tabs 만. 나머지 4 모드는 G.2 / G.2.5 범위 밖
6. **DataPort 활성화 안 함** — G.0 결정대로 props 기반만. 차후 phase
7. **`v2-tabs-widget` 마이그레이션 adapter** — 기존 스크린에 저장된 `v2-tabs-widget.TabItem.components: TabInlineComponent[]` 를 canonical `ContainerTab.components: ContainerChildComponent[]` 로 변환하는 adapter 는 미구현. `templateMigrate.ts` 의 `'v2-tabs-widget': 'container'` 매핑은 이미 있지만 components 배열 shape 차이 (`component_type` ↔ `componentType`) 는 그대로. canonical 컨테이너가 둘 다 읽을 수 있게 ChildSlot 의 컴포넌트 정규화가 snake_case + camelCase 둘 다 채워서 보내므로 표면적 동작은 OK. 명시 마이그 SQL 은 작성 금지 (G.0 원칙)

#### Studio 에서 테스트하는 방법

1. ScreenDesigner 진입 후 좌측 팔레트에서 "container" 컴포넌트를 캔버스에 drop
2. 컨테이너 선택 → 우측 InvContainerConfigPanel ① 에서 "탭" 종류 선택 (CPVisualGrid 의 두 번째 카드)
3. ② 유형별 설정 의 "탭 추가" 버튼으로 탭 3개 만들기 + 각 탭 라벨 변경 (예: "사원 목록 / 부서별 현황 / 근태 현황")
4. 캔버스에서 탭 헤더 클릭 → 활성 탭 전환. 활성 탭 body 는 "탭이 비어 있습니다" dashed 영역
5. 좌측 팔레트에서 `table` (또는 `stats` / `search` / `button` / `input`) 을 드래그 → 활성 탭 body 위에 drop
6. 자식이 즉시 vertical stack 위에 등장 + 우측 InvContainerConfigPanel ② 의 탭 row 에 "(1개)" 뱃지 증가
7. 다른 탭으로 전환 → 그 탭은 빈 dashed 영역. 첫 탭의 자식 mount 는 해제됨 (탭별 독립 확인)
8. 5~7 반복 → 탭마다 다른 자식 배치
9. 자식 hover → primary dashed outline + 우측 상단 ✕ 버튼. 자식 클릭 → primary solid outline (선택). ✕ 클릭 → 자식 삭제
10. 운영 모드 / 미리보기 진입 시 자식이 실제 컴포넌트로 동작 (테이블이면 데이터 fetch 등). 디자인 모드 outline / ✕ 버튼은 자동 사라짐

---

### Phase G.2.6 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

G.2.5 가 끝낸 "canonical container 탭 안에 컴포넌트 drop / 선택 / 삭제" 위에, **자식 컴포넌트를 클릭하면 우측 properties 패널 (V2PropertiesPanel) 이 그 자식의 설정을 보여주고 편집까지 가능** 하도록 wiring. 이제 자식이 일반 최상위 컴포넌트와 동일한 편집 UX 를 갖는다.

#### 파일 rename 사실

- 기존 빌더 컴포넌트 `frontend/components/screen/ScreenDesigner.tsx` 는 `InvyoneStudio.tsx` 로 rename 완료. 본 phase 가 작업한 파일은 **`InvyoneStudio.tsx`** 뿐. `ScreenDesigner_old.tsx` 는 legacy backup 이며 손대지 않음
- `@/components/screen/ScreenDesigner` import 는 grep 0건이어야 함

#### Changed files

Frontend (Next / TS):
- `frontend/components/screen/InvyoneStudio.tsx`
  - 활성 우측 properties panel 분기 (line ~8782) 확장. 단일 `selectedComponent` 체크에서 **4-way branch** 로:
    1. `selectedTabComponentInfo` → 탭 자식 어댑터 + 핸들러로 `V2PropertiesPanel` 렌더
    2. `selectedPanelComponentInfo` → 분할 패널 자식 어댑터 + 핸들러
    3. `selectedComponent` → 기존 동작
    4. 둘 다 없으면 안내 메시지
  - 새 inline 핸들러: `updateTabChildProperty / deleteTabChild / updatePanelChildProperty / deletePanelChild`. 모두 `setNestedValue` 로 깊은 path 안전 업데이트 + `saveToHistory(newLayout)` 호출
- 기타 변경 없음. **disabled 좌측 패널 블록 (`{false && (...)}`) 은 그대로 비활성 유지**. 로직만 우측으로 이식

Backend: 변경 없음.
Documentation: 본 섹션 추가.

#### 우측 properties 패널 선택 우선순위

```
selectedTabComponentInfo   (canonical container tabs / 옛 v2-tabs-widget 자식)
      ↓ (있으면 stop)
selectedPanelComponentInfo (split-panel left / right 자식)
      ↓ (있으면 stop)
selectedComponent          (최상위 컴포넌트)
      ↓
"캔버스에서 블록을 선택하세요" 안내
```

세 selection state 는 서로 배타적으로 관리됨:

- `handleComponentSelect` (`InvyoneStudio.tsx:600`) — 최상위 컴포넌트 선택 시 `setSelectedTabComponentInfo(null)` + `setSelectedPanelComponentInfo(null)`
- `handleSelectTabComponent` (line 618) — 탭 자식 선택 시 `setSelectedComponent(null)` + `setSelectedPanelComponentInfo(null)`
- `handleSelectPanelComponent` (line 651) — 분할 패널 자식 선택 시 `setSelectedComponent(null)` + `setSelectedTabComponentInfo(null)`
- 탭 자식 ChildSlot 또는 탭 body 빈 영역 click → `onSelectTabComponent(tabId, "", null)` → `selectedTabComponentInfo = null`. 다른 state 가 없으면 4번 분기 (안내)

#### 자식 어댑터 shape

`selectedTabComponentInfo.component` (또는 `selectedPanelComponentInfo.component`) 를 `V2PropertiesPanel` 이 기대하는 `ComponentData` 로 변환:

```ts
{
  id: child.id,
  type: "component",
  componentType: child.componentType,  // "input" | "table" | "stats" | "search" | "button" | "container" | ...
  label: child.label,
  position: child.position ?? { x: 0, y: 0 },
  size: child.size ?? { width: 200, height: 100 },
  componentConfig: child.componentConfig ?? {},
  style: child.style ?? {},
}
```

canonical 컴포넌트 id (예: `input / table / stats / search / button / container`) 는 그대로 보존. `getComponentConfigPanel` 의 canonical id 매핑이 정상 동작하므로 추가 코드 없이 동작.

#### Nested update path 처리

`onUpdateProperty(componentId, path, value)` 가 깊은 path 를 받는다. `setNestedValue` 가 path 를 `.` split 해서 deep clone 위에 재할당. 예:

- `label` → 단순 top-level key
- `size.width` → `{ ...comp, size: { ...comp.size, width: value } }`
- `componentConfig.title` → `{ ...comp, componentConfig: { ...comp.componentConfig, title: value } }`
- `componentConfig.dataSource.tableName` → 깊이 3 path. 중간 객체 없으면 자동 생성
- `style` (단일 키, 객체 통째 교체) → `path === "style"` 분기에서 `{ ...comp, style: value }`
- `style.labelDisplay` → 깊이 2 path. 동일하게 처리

탭 자식 / 분할 패널 자식 양쪽 모두 동일 `setNestedValue`. 자식 편집 UX 가 최상위 편집과 사실상 동일.

#### Selection 동기화

자식 컨피그 변경 후 `selectedTabComponentInfo.component` 도 동시에 갱신:

```ts
if (updatedTabs) {
  const updatedComp = updatedTabs
    .find(t => t.id === tabId)?.components
    ?.find(c => c.id === componentId);
  if (updatedComp) {
    setSelectedTabComponentInfo(prev => prev ? { ...prev, component: updatedComp } : null);
  }
}
```

→ V2PropertiesPanel 이 다음 render 에서 갱신된 자식 데이터를 받음. onChange 가 panel 재렌더로 이어져도 "쓴 즉시 사라지는" 현상 없음.

#### 삭제 핸들러

자식 properties panel 의 휴지통 / 삭제 액션 → `deleteTabChild(componentId)` 또는 `deletePanelChild(componentId)`:

1. 해당 `tab.components` (또는 `panel.components`) 에서 `c.id !== componentId` 만 남기는 `filter`
2. 갱신된 컨테이너로 layout.components 업데이트
3. `setSelectedTabComponentInfo(null)` (또는 panel 쪽) — selection 해제
4. `saveToHistory(newLayout)`

→ 자식이 즉시 사라지고 우측 패널이 안내 메시지로 복귀.

ChildSlot 의 ✕ 버튼 (G.2.5) 은 별개 경로 — 컨테이너의 `onUpdateComponent` 를 직접 호출. 둘 다 같은 결과 (자식 제거 + 선택 해제). 동시 활성화돼도 race 없음 (둘 다 immutable update).

#### 중첩 구조 (split-panel 안의 canonical container 탭)

`selectedTabComponentInfo.parentSplitPanelId` + `parentPanelSide` 가 set 되어 있으면 자동으로 중첩 update 분기. canonical container 가 분할 패널의 leftPanel / rightPanel 안에 있어도 자식 properties 편집이 정상 작동. 옛 disabled 좌측 블록의 분기 패턴 그대로 이식.

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches |
| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches |
| `rg "@/components/screen/ScreenDesigner\|<ScreenDesigner\|export default function ScreenDesigner\|interface ScreenDesignerProps"` | 0 matches (rename 완료) |
| `tsc --noEmit` G.2.6 변경 영역 (line 8782~9150) | 새 type 카테고리 0건. 기존 type debt 패턴 (TableInfo legacy vs canonical import 미스매치, `as ComponentData` 변환 경고) 만 callsite 수만큼 늘어남 |
| `tsc --noEmit` 전체 | 3388 (G.2.5 종료 3376 + 12 callsite-증가) |

12개의 새 에러 분해:
- TableInfo `screen-legacy-backup` vs `table-management` import 불일치 — 새 V2PropertiesPanel 2개 callsite (tab child / panel child) 추가로 callsite 증가
- `as ComponentData` 변환 경고 — tab / panel adapter 2개 위치
- 기타 GroupState / 인접 미스매치 — pre-existing 동일 패턴

→ 새 카테고리 0. 모두 pre-existing 패턴의 callsite 중복. 본 phase 에서 풀 수 있는 type debt 아님 (전역 import 정리 phase 필요).

#### Studio 에서 테스트하는 방법

1. ScreenDesigner (=InvyoneStudio) 진입 → 좌측 팔레트에서 `container` drop
2. 컨테이너 선택 → 우측 InvContainerConfigPanel ① 에서 "탭" 카드 클릭
3. "탭 추가" 로 탭 3개 만들기 + 라벨 변경 (예: "사원 목록 / 부서별 현황 / 근태 현황")
4. 활성 탭 body 에 `table` / `stats` / `search` / `input` drop
5. **자식 클릭 → 우측 패널이 즉시 그 자식의 설정 패널로 전환** (InvTableConfigPanel / InvStatsConfigPanel / InvSearchConfigPanel / InvFieldConfigPanel 등)
6. 자식 설정 변경 (예: stats label 텍스트, table 컬럼, input placeholder, button 색) → 캔버스 자식 즉시 반영
7. 다른 탭으로 전환 → 그 탭의 자식 mount 해제. 이전 탭 자식 설정은 보존
8. 자식 선택 상태에서 우측 패널 휴지통 → 자식 삭제 + 우측 패널 안내 메시지 복귀
9. 컨테이너 자체 (탭 헤더 영역) 클릭 → 우측 패널이 InvContainerConfigPanel 로 복귀 (자식 선택 해제됨)
10. ✕ 버튼 (G.2.5) 도 그대로 동작 — 휴지통 vs ✕ 어느 쪽으로 삭제해도 같은 결과

#### Remaining limitations

1. **다중 선택** — 한 번에 한 자식만 선택. 다중 선택 + 일괄 편집은 별도 phase
2. **자식 reorder / 드래그 위치 변경** — vertical stack 순서만. 자유 위치 / drag-reorder 미지원
3. **컬럼 드래그 → 자식 input 자동 생성** — `handleDrop` 의 column 분기는 canonical container tabs 인식 X
4. **section / accordion / repeater / conditional 자식 편집** — G.2.6 wiring 은 tabs / split-panel 만
5. **자식 카피 / 클립보드** — `onCopyComponent` 는 tab/panel 자식 분기에서 전달 안 함. 자식 복제 UI 가 필요해지면 별도 작업
6. **history scope** — saveToHistory 가 layout 단위로 동작. 자식 단일 변경도 layout 전체 snapshot 으로 history 추가. KPI 단위 자식 편집이 많아지면 history 메모리 증가 — 별도 최적화 (path-level diff) 필요할 수 있음
7. **DataPortBus 비활성 유지** — Phase G.0 결정대로 props 기반만
8. **type debt** — `tables` prop 의 TableInfo 두 import path 미스매치, ComponentData 변환 경고는 전역 type 정리 phase 에서 일괄 해결 권장. G.2.6 은 callsite 수를 늘렸지만 카테고리는 동일

#### Codex verification correction

검증 중 확인된 보정:
- 새 우측 panel callsite 2곳(tab child / panel child)의 `V2PropertiesPanel` props 에서 기존 `TableInfo` import path 미스매치가 불필요하게 추가 노출되지 않도록 `tables/currentTable` 을 명시 캐스팅
- tab/panel child adapter 의 `ComponentData` 변환도 `unknown as ComponentData` 로 명시해 rename 이후 전체 type debt 와 별개로 G.2.6 callsite 가 새 경고를 늘리지 않게 조정

---

### Phase G.3 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

탭 안에서 사용할 범용 뷰 컴포넌트 중 **차트** 를 canonical 로 추가. G.2.5/G.2.6 위에서 사용자가 캔버스의 탭 자식으로 `chart` 를 drop → 우측 InvChartConfigPanel 에서 테이블 / groupBy / 집계 / 필터 설정 → recharts 로 즉시 렌더. HR 전용 코드 없음. 모든 도메인 (부서별 인원 / 매출 / 재고 / 사용자 상태 등) 에 재사용 가능.

`cardList / groupedTable / searchFilter` 는 본 phase 범위 밖으로 분리 → **G.3.1 (cardList)** 로 별도 표시.

#### Changed files

Backend (Java / Spring):
- `backend-spring/src/main/java/com/erp/service/TableManagementService.java` — `aggregateTableGroup(tableName, options)` 신규 메서드 추가
- `backend-spring/src/main/java/com/erp/controller/TableManagementController.java` — `POST /api/table-management/tables/{tableName}/aggregate-group` endpoint 추가

Frontend (Next / TS) — 신규:
- `frontend/lib/registry/components/chart/types.ts` — `ChartType / ChartDataSource / ChartConfig`
- `frontend/lib/registry/components/chart/use-chart-data.ts` — runtime fetch hook (preview / cache / race guard / error 격리)
- `frontend/lib/registry/components/chart/ChartComponent.tsx` — recharts 위 4종 (`bar` / `horizontalBar` / `line` / `donut`)
- `frontend/lib/registry/components/chart/ChartRenderer.tsx` — `AutoRegisteringComponentRenderer` 자가등록
- `frontend/lib/registry/components/chart/index.ts` — `ChartDefinition` (`createComponentDefinition`)
- `frontend/lib/registry/components/chart/InvChartConfigPanel.tsx` — cp 시스템 4-section 패널

Frontend (Next / TS) — 수정:
- `frontend/lib/api/stats.ts` — `aggregateTableGroup(tableName, request)` + `AggregateGroupRequest / AggregateGroupRow / AggregateGroupResponse` 타입 추가
- `frontend/lib/registry/components/index.ts` — `import "./chart/ChartRenderer";` 한 줄 추가
- `frontend/lib/utils/getComponentConfigPanel.tsx` — `"chart" / "chart-basic"` CONFIG_PANEL_MAP 엔트리를 옛 `components/screen/config-panels/ChartConfigPanel` 에서 새 canonical `lib/registry/components/chart/InvChartConfigPanel` 로 재매핑
- `frontend/components/screen/panels/ComponentsPanel.tsx` — `BASIC_IDS` Set 에 `"chart"` 추가 → 팔레트의 "기본" 탭에 노출

Documentation: 본 섹션 추가.

#### Backend endpoint contract

```
POST /api/table-management/tables/{tableName}/aggregate-group
{
  "aggregation": "count" | "sum" | "avg" | "min" | "max" | "distinctCount",
  "groupBy": "status",
  "valueColumn": "amount" | undefined,
  "filters": [{ "column", "operator", "value" }] | [],
  "limit": 12,
  "orderDir": "desc" | "asc"
}
→ ApiResponse<{ rows: [{ group, value }, ...] }>
```

검증:
- `sanitize` + `hasColumn` 으로 식별자 안전성 (테이블 / groupBy / valueColumn 모두)
- `count` 외 집계는 `valueColumn` 필수 — 없으면 400
- `sum / avg` 만 `CAST(col AS NUMERIC)` 자동 변환 (text 컬럼도 안전)
- `filters` 는 `/aggregate` 와 동일한 11종 operator (`=, !=, >, <, >=, <=, like, in, notIn, isNull, isNotNull`). 같은 `buildAggregateWhere` 헬퍼 공유
- `limit` 은 1~500 (기본 50, chart 기본 12)
- 결과 정렬: `ORDER BY agg_value <orderDir> NULLS LAST`. 큰 그룹부터 (기본 desc) 또는 작은 그룹부터

#### Frontend runtime behavior

ChartComponent 가 `useChartData(dataSource, { isDesignMode, formData, userContext, refreshKey, includeEmptyGroup })` 를 호출:

- **디자인 모드**: API 호출 0. 정적 preview 4 row (`{ A:28, B:19, C:12, D:6 }`) 만 그려서 차트 모양 미리보기
- **dataSource 미완성** (테이블 / groupBy 없음 또는 valueColumn 누락 시): 같은 preview 로 fallback. `isPreview === true` flag
- **운영 모드 + 완성된 dataSource**: `/aggregate-group` 호출. request key 캐시 (같은 호출 중복 0). version counter race guard
- **실패**: 카드 자체가 죽지 않고 "데이터 로드 실패: <message>" 표시
- **빈 그룹** (`group === null || group === ""`): 기본은 필터 아웃. `includeEmptyGroup: true` 면 "—" 라벨로 표시
- `OptionFilter.value_type` 의 `field` / `user` 는 hook 안에서 `formData` / `userContext` 로 미리 치환 (stats 와 동일 패턴). 컨텍스트 누락 시 preview 모드로 유지

#### Config panel UX

4-section 흐름:

1. **① 기본** — 제목 + 차트 종류 (CPVisualGrid 4종: bar / horizontalBar / line / donut, 아이콘 포함)
2. **② 데이터 소스** — 테이블 (CPSelect, `useDbTables`) / 그룹 컬럼 / 집계 / 값 컬럼 (count 외만 노출)
3. **③ 필터** — `OptionFilter` 빌더 (stats 의 FilterRow 와 동일 패턴, column / operator / value_type / value). `isNull / isNotNull` 일 때 값 입력 숨김
4. **④ 표시 옵션** — 최대 그룹 (CPNumber 1~500) / 정렬 (CPSegment) / 빈 그룹 포함 (CPSwitch) / 축 표시 (CPSwitch) / donut 면 범례 표시, bar·line 이면 막대·선 색

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `cd backend-spring && ./gradlew compileJava` | BUILD SUCCESSFUL in 3s |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches |
| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches |
| `rg "@/components/screen/ScreenDesigner\|<ScreenDesigner\|export default function ScreenDesigner\|interface ScreenDesignerProps"` | `ScreenDesigner_old.tsx` 만 매치 (legacy backup, 활성 import 0건) |
| `tsc --noEmit` G.3 변경 파일 한정 (`chart/`, `lib/api/stats.ts`, `getComponentConfigPanel.tsx`) | 0 errors |
| `tsc --noEmit` 전체 | 3376 (G.2.6 종료 3388 → **-12**). 옛 `components/screen/config-panels/ChartConfigPanel` 가 더 이상 lazy-load 의존되지 않아 그 안의 type debt 가 사라짐 |

CPSwitch API 미스매치 (initial pass `checked / onCheckedChange` → 실제는 `value / onChange`) 는 첫 tsc 에서 발견 후 fix. 최종 한 번에 0건.

#### Studio 에서 테스트하는 방법

1. ScreenDesigner (=InvyoneStudio) 진입 → 좌측 팔레트에서 `container` drop → 컨테이너 선택 → "탭" 타입 + 탭 3개 ("사원 목록 / 부서별 현황 / 근태 현황")
2. "부서별 현황" 탭 활성 후 body 위에 좌측 팔레트의 `chart` (기본 그룹) drop
3. 자식 차트 클릭 → 우측 패널이 InvChartConfigPanel 로 전환
4. ① 에서 종류 "막대" 선택
5. ② 테이블 = `user_info` (예시), 그룹 컬럼 = `dept_code`, 집계 = `건수`, 값 컬럼 = 빈칸 (count 면 불필요)
6. ② 의 입력 끝나는 순간 캔버스 차트가 정적 preview 에서 실제 데이터로 전환
7. ④ 표시 옵션에서 정렬 = "값 큰 순", 최대 그룹 = 6 으로 줄이면 즉시 반영
8. 다른 탭으로 전환 → 차트 mount 해제. 돌아오면 같은 데이터로 다시 fetch (캐시 hit). refreshKey 없으니 자연스러움
9. 새 탭에 `table` drop → 같은 방식으로 컬럼 / 테이블 / 필터 설정. 차트와 테이블이 독립적으로 자기 데이터 fetch — 서로 영향 없음
10. 운영 모드 / 미리보기 진입 시 chart / table 모두 실제 데이터로 동작

#### Remaining limitations

1. **cardList / groupedTable / searchFilter 미구현** → **G.3.1** 로 분리 권장. canonical `table` + `search` 가 일부 기능 흡수 가능하지만 카드 그리드형 / 날짜 그룹 row 같은 특수 view 는 별도 컴포넌트가 깔끔
2. **컬럼 선택 dropdown 미지원** — chart 의 `groupBy / valueColumn` 은 free text. 테이블 선택 후 컬럼 자동완성은 stats 와 마찬가지로 후속 작업 (`tableManagementApi.getColumnList` 재사용)
3. **시계열 정렬** — line chart 의 X 축은 `groupBy` 컬럼 값 순서가 아니라 집계 값 순서 (`orderDir`) 로 정렬됨. 시계열 차트는 `groupBy` 가 날짜 컬럼일 때 `orderDir: "asc"` 로 사용 권장. 진짜 시계열 (period 그룹화 + xy 정렬) 은 별도 phase
4. **다중 측정값 (stacked / grouped bar)** — 현재는 단일 measure. 한 차트에 여러 값 비교는 별도 phase
5. **drill-down / 클릭 인터랙션** — chart 클릭 시 DataPort 로 selectedGroup 발행 등은 G.0 결정대로 미활성
6. **차트 라벨 i18n** — 그룹값이 null / "" 면 "—" 로 표시. 다국어 문구 키는 별도 일관 정리 필요
7. **자동 갱신 / polling** — `refreshKey` 변경 시 재호출. 주기 polling 미지원
8. **palette 노출 시 alias 미설정** — `chart-basic` 은 ConfigPanelMap 에는 같이 매핑되지만, 옛 placeholder `frontend/lib/registry/components/ChartRenderer.tsx` (legacy registry 의 `componentRegistry.register("chart", ...)`) 는 import 되지 않으므로 충돌 0. 향후 정리 phase 에서 placeholder 파일 자체 삭제 권장

#### Codex verification correction

검증 중 확인된 보정:
- `/aggregate-group` 의 `filters` 처리도 `/aggregate` 와 같은 `normalizeAggregateFilters` 경로로 변경. 잘못된 payload / non-map 항목이 섞여도 `ClassCastException` 으로 500 이 나지 않고 안전하게 normalize/skip 된다.

#### G.3.1 후속 (별도 phase)

- **cardList** — 같은 `/aggregate-group` 또는 `/data` 응답을 카드 그리드로. titleField / subtitleFields / metricFields config. 부서별 카드 + 우측 액션 버튼 등 가벼운 wrapper
- **groupedTable** — `/data` 응답을 클라이언트 측에서 `groupBy` 로 묶어 날짜 / 부서 단위 row 헤더 + 안쪽 row 들 (근태 현황 화면 검증용)
- **searchFilter 통합** — canonical `search` 가 이미 일부 흡수. 탭 별 filter 와 dataSources 의 wiring 은 G.4 인사 관리 화면 검증과 함께 진행

---

### Phase G.3.1 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

G.3 차트 위에 **카드 리스트 (`card-list`)** 와 **그룹 테이블 (`grouped-table`)** canonical 컴포넌트 두 개를 추가. 부서 / 사용자 / 품목을 카드 카탈로그로 보거나, 근태 / 일자별 / 카테고리별처럼 row 를 묶어 보는 일반화된 view 컴포넌트. HR 전용 코드 0건. 모든 도메인 (인사 / 영업 / 재고 / CRM) 재사용 가능.

#### Changed files

Backend (Java / Spring):
- `backend-spring/src/main/java/com/erp/service/TableManagementService.java` — `selectTableRows(tableName, options)` 신규 메서드. sanitize / hasColumn / `buildAggregateWhere` 재사용
- `backend-spring/src/main/java/com/erp/controller/TableManagementController.java` — `POST /api/table-management/tables/{tableName}/select-rows` endpoint

Frontend (Next / TS) — 신규:
- `frontend/lib/registry/components/_shared/use-table-rows.ts` — card-list / grouped-table 공유 fetch hook (preview / cache / race guard / OptionFilter 치환)
- `frontend/lib/registry/components/_shared/FilterRow.tsx` — stats / chart / cardList / groupedTable 공유 OptionFilter 빌더 row (`OptionFilterRow`)
- `frontend/lib/registry/components/card-list/types.ts` — `CardListConfig / CardListLayout / CardListMetric / CardListDataSource`
- `frontend/lib/registry/components/card-list/CardListComponent.tsx` — list / grid 두 레이아웃, title/subtitles/metrics 조합
- `frontend/lib/registry/components/card-list/CardListRenderer.tsx` — 자가 등록
- `frontend/lib/registry/components/card-list/index.ts` — `CardListDefinition`
- `frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx` — cp 4-section
- `frontend/lib/registry/components/grouped-table/types.ts` — `GroupedTableConfig / GroupedTableColumn / GroupedTableDataSource`
- `frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx` — table + 그룹 헤더 row
- `frontend/lib/registry/components/grouped-table/GroupedTableRenderer.tsx` — 자가 등록
- `frontend/lib/registry/components/grouped-table/index.ts` — `GroupedTableDefinition`
- `frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx` — cp 5-section

Frontend (Next / TS) — 수정:
- `frontend/lib/api/stats.ts` — `selectTableRows(tableName, request)` + `SelectRowsRequest / SelectRowsOrderBy / SelectRowsResponse` 타입 추가
- `frontend/lib/registry/components/index.ts` — `card-list` / `grouped-table` renderer 두 import 추가
- `frontend/lib/utils/getComponentConfigPanel.tsx` — `"card-list"` / `"grouped-table"` CONFIG_PANEL_MAP 엔트리
- `frontend/components/screen/panels/ComponentsPanel.tsx` — `BASIC_IDS` Set 에 `"card-list"` / `"grouped-table"` 추가

Documentation: 본 섹션 추가.

#### Backend endpoint contract

```
POST /api/table-management/tables/{tableName}/select-rows
{
  "columns": ["user_name", "dept_code", ...],           // 비우면 SELECT *
  "filters": [{ column, operator, value }],
  "orderBy": [{ column, direction: "asc" | "desc" }],   // 비우면 created_date desc (있을 때)
  "limit": 50,                                          // 기본 50, 최대 500
  "offset": 0
}
→ ApiResponse<{ rows: [{...}, ...] }>
```

검증:
- `sanitize` + `hasColumn` 식별자 안전성 (table / columns / filter columns / orderBy columns 모두)
- 없는 컬럼은 자동 drop (`columns` 중 일부만 유효하면 유효한 것만 SELECT, orderBy 도 마찬가지)
- `filters` 는 `/aggregate` / `/aggregate-group` 와 동일한 11종 operator. `buildAggregateWhere` 공유 헬퍼
- 값은 JdbcTemplate `?` 파라미터 바인딩만. 식별자에 raw input 안 들어감
- `limit` 1~500 (기본 50), `offset` ≥ 0
- 잘못된 tableName → `IllegalArgumentException` → 400

#### Frontend runtime — useTableRows

shared hook `_shared/use-table-rows.ts` 를 card-list / grouped-table 둘이 공유:

- **디자인 모드**: API 호출 0. `previewBuilder?` 로 컴포넌트별 맞춤 preview row 4~6장 생성
- **dataSource 미완성** (`tableName` 없음): 같은 preview 로 fallback. `isPreview === true`
- **운영 모드**: `/select-rows` 호출. request key 캐시 (`tableName + columns + filters + orderBy + limit` stringify) — 같은 호출 중복 0
- **race guard**: version counter 로 stale response 무시
- **실패**: throw 안 하고 `error` state 반환. 각 컴포넌트가 fallback 메시지 표시
- **filter resolver**: `OptionFilter.value_type === "field" | "user"` → hook 내부에서 `formData` / `userContext` (companyCode, userId, deptCode, userName) 로 치환. 컨텍스트 누락 시 preview 모드로 폴백

#### card-list (id `"card-list"`)

- 레이아웃: `list` (한 줄 카드, 우측 metric 정렬) 또는 `grid` (auto-fit, 200px 최소폭)
- 필드 매핑: `titleField` (위 큰 글씨) + `subtitleFields[]` (라인별 작은 글씨) + `metricFields[]` (`{ column, label?, emphasis? }`)
- `emphasis: true` → metric 칩이 primary 색 강조 (`var(--v5-primary)`)
- 빈 결과 / 에러 메시지 customizable (`emptyText`)

#### grouped-table (id `"grouped-table"`)

- 컬럼 정의: `GroupedTableColumn[] = { column, label?, width?, align? }`. 비우면 row 첫 항목의 모든 키 자동 표시 (`groupBy` 자체 제외, `__` prefix 키 제외)
- 클라이언트 측 group-by: `Map<key, row[]>` 으로 묶음 + `sortGroups: "none" | "asc" | "desc"` (한국어 collator)
- 그룹 헤더: primary 색 라벨 + `(N건)` 카운트
- `cellToText` 헬퍼: `null/undefined/""` → `"—"`, `number` → `toLocaleString`, `boolean` → `✓/✗`, `Date` → `toLocaleString`

#### Config panel UX

`OptionFilterRow` 공유 컴포넌트로 stats / chart / cardList / groupedTable 4 패널이 같은 필터 빌더 사용. 기존 stats / chart 의 inline FilterRow 와 정확히 동일 UX (column / operator / value_type / value, `isNull/isNotNull` 일 때 값 입력 숨김).

cardList 패널 (4-section):
1. ① 기본 — 제목 + 레이아웃 (CPSegment) + grid 컬럼 수 + 빈 결과 문구
2. ② 데이터 소스 — 테이블 (CPSelect) / 정렬 컬럼 / 정렬 방향 / 최대 행
3. ③ 필드 매핑 — 제목 컬럼 + 부제 컬럼 N개 + metric 칩 N개 (column / label / emphasis switch)
4. ④ 필터 — OptionFilterRow 빌더

groupedTable 패널 (5-section):
1. ① 기본 — 제목 / 빈 결과
2. ② 데이터 소스 — 테이블 / 정렬 / 최대 행
3. ③ 그룹 — groupBy 컬럼 / 그룹 정렬 (원본 / 오름 / 내림)
4. ④ 컬럼 — 표시 컬럼 목록 (column / label / width / align)
5. ⑤ 필터

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `cd backend-spring && ./gradlew compileJava` | BUILD SUCCESSFUL in 1s |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | 0 matches |
| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches |
| `rg "@/components/screen/ScreenDesigner\|..."` | `ScreenDesigner_old.tsx` 만 (legacy backup, 활성 import 0) |
| `tsc --noEmit` G.3.1 변경 파일 한정 | 0 errors |
| `tsc --noEmit` 전체 | 3376 (G.3 종료와 동일 — G.3.1 신규 0건) |

#### Codex verification follow-up

검증 중 발견한 운영 리스크 2건을 같은 Phase G.3.1 범위 안에서 보정:

- `selectTableRows` 의 `filters` / `orderBy` 입력을 unchecked cast 대신 `normalizeAggregateFilters(...)` 로 정규화. 잘못된 shape 가 들어와도 `ClassCastException` 으로 500 이 나지 않고, 유효한 map 항목만 처리된다.
- `card-list` / `grouped-table` 의 `previewBuilder` 를 `useCallback` 으로 고정. 디자인 모드 또는 dataSource 미완성 상태에서 preview row 재생성 → effect 재실행 → state update 반복으로 이어질 수 있는 루프를 차단했다.

#### Studio 에서 테스트하는 방법

1. ScreenDesigner (=InvyoneStudio) 진입 → 좌측 팔레트에서 `container` drop → containerType "탭" + 탭 3개 ("사원 목록 / 부서별 현황 / 근태 현황")
2. **"부서별 현황" 탭** 활성 → 좌측 팔레트 `chart` drop → 클릭하면 우측 InvChartConfigPanel. ② 에서 테이블 = `user_info`, 그룹 컬럼 = `dept_code`, 집계 = `건수` → 즉시 막대 차트
3. 같은 탭에 추가로 `card-list` drop → 우측 InvCardListConfigPanel. ② 테이블 = `dept_info` (예시), 정렬 = `dept_name asc`, 최대 = 20. ③ 제목 컬럼 = `dept_name`, 부제 = `[dept_code]`, metric = `[{column: "head_count", label: "인원", emphasis: true}]` → 부서 카드 카탈로그
4. **"근태 현황" 탭** 활성 → `grouped-table` drop → 우측 InvGroupedTableConfigPanel. ② 테이블 = `attendance`, 정렬 = `attendance_date desc`. ③ 그룹 = `attendance_date`, 그룹 정렬 = `desc`. ④ 컬럼 = `[{column: "user_name", label: "사원"}, {column: "check_in", label: "출근"}, {column: "check_out", label: "퇴근"}, {column: "work_hours", label: "근무시간", align: "right"}]` → 날짜별 그룹 row + 안쪽 사원 row
5. 탭 전환 → 각 탭의 컴포넌트가 독립적으로 mount/unmount, 데이터 서로 영향 없음
6. 운영 모드 / 미리보기 진입 시 chart / card-list / grouped-table 모두 실 데이터로 동작

#### Remaining limitations

1. **컬럼 자동완성 dropdown 미지원** — card-list / grouped-table 의 columns / groupBy / orderBy 컬럼은 모두 free text. `tableManagementApi.getColumnList` 활용은 후속 phase 일괄 처리 권장 (stats / chart 도 동일 한계)
2. **server-side 페이지네이션 / 무한 스크롤** — card-list / grouped-table 모두 `limit` 으로 첫 N행만 가져옴. 페이지네이션 UI 는 별도 (Phase G.4 이후)
3. **drill-down / 클릭 인터랙션** — 카드 / 그룹 row 클릭 시 DataPort publish 등은 G.0 결정대로 미활성
4. **grouped-table 의 그룹 접기/펼치기** — 항상 펼침. collapse 토글은 별도 작업
5. **그룹 헤더 집계 (sum / avg)** — 현재 그룹 헤더는 `(N건)` 카운트만. 그룹 별 column 합계 / 평균 표시는 후속
6. **i18n** — `"데이터 없음"`, `"건"`, `"미리보기"` 등 한국어 fixed. 다국어는 별도 일관 작업
7. **자동 갱신** — `refreshKey` 변경 시 재호출. polling 미지원
8. **자식의 자유 위치** — canonical container 탭 안에서 자식 컴포넌트는 vertical stack 만. position.x/y 무시 (G.2.5 결정 유지)

#### 다음 권장 단계

- **Phase G.4 인사 관리 화면 검증** — canonical stats / chart / card-list / grouped-table / table / search / button / input 만으로 인사 관리 예시 화면 (상단 통계 카드 4 + 사원 목록 탭 + 부서별 현황 탭 + 근태 현황 탭) 재현. 화면 이름과 예시 데이터는 인사 관리여도 구현은 범용 유지
- **Phase G.5 Legacy Cleanup** — `frontend/lib/registry/components/ChartRenderer.tsx` placeholder, `frontend/components/screen/config-panels/ChartConfigPanel.tsx` 옛 placeholder, `v2-aggregation-widget` / `v2-status-count` / `v2-tabs-widget` 등 alias-only legacy 폴더 일괄 제거 검토

---

### Phase G.4.1 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

canonical `stats` 항목 편집 UX 가 정적 카드 가정으로 동작해, DB-bound KPI 카드 (`전체 사원 / 재직 중 / 휴직 중 / 겸직 현황`) 에 부적합. 사용자가 값을 직접 타이핑하고 변화량 (delta) 까지 필수처럼 강요되는 인상을 줌. G.4.1 은 stats 의 **편집 UX 를 DB-first** 로 정리. 정적 fallback 은 보존하되 보조 자리로 내림.

#### Changed files

Frontend (Next / TS):
- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 항목 row 재설계
  - 단일 sourceTable 데이터 소스 섹션 ②, "변화량" 메인 row 제거
  - collapsed row 에 **DB / 정적 모드 뱃지 (`ModeBadge`)** 추가. 클릭 1번으로 모드 전환
  - DB 모드: collapsed 에서 값 입력 숨김, expanded 에서 DB 바인딩 (테이블 / 집계 / 컬럼 / 필터) 우선 표시
  - 정적 모드: collapsed 에서 fallback 값 입력 보임, expanded 에서 "정적 값" 박스로 명확화
  - 외형 (아이콘 / 색) 은 양쪽 공통 박스
  - **비교 / 변화량** 은 expanded 안의 접기-펼치기 sub-section, 기본 닫힘. `item.delta` 비우면 카드에 비표시
  - local `FilterRow` 제거, 공유 `OptionFilterRow` (`_shared/FilterRow`) 사용
  - `addItem` 의 default `value: 0` 제거 → label 만 가진 빈 항목 (DB 모드 전환 후 자연스럽게 "—" 표시)
  - 색상 프리셋이 항목 4개 자동 생성할 때도 `value: 0` 박지 않음
- `frontend/lib/registry/components/stats/StatsComponent.tsx` — runtime fallback 의미 정리
  - default items 도 `value: 0` 제거 (label 만)
  - `displayValue` 결정: DB-bound + fetch 미완 / 디자인 모드 → `item.value !== undefined ? formatValue(item.value) : "—"`. 강제 `value ?? 0` 제거
  - 정적 모드도 동일하게 명시 안 한 값은 "—". lucide icon 렌더링 (`renderIcon`) 보존
- Documentation: 본 섹션 추가.

Backend / API 변경 없음.

#### UX 의 새 의미 (한국어 정리)

| 모드 | 의미 | collapsed row | expanded 우선 표시 |
|---|---|---|---|
| **DB** | 값은 DB 집계로 자동 계산. 정적 입력 없음. | 번호 · 색점 · 라벨 · `[DB]` 뱃지 (primary) · 펼침 · 삭제 | DB 바인딩 (테이블 / 집계 / 컬럼 / 필터) → 외형 → 비교/변화량 (옵션) |
| **DB (테이블 미설정)** | DB 모드 토글했지만 테이블 미선택 — 디자인 모드 "—" 표시 | `[DB]` 뱃지가 amber + tooltip 안내 | DB 바인딩 헤더에 "테이블 미설정" 라벨 |
| **정적** | DB 미사용. 사용자 입력 값 또는 미입력 시 "—". | 번호 · 색점 · 라벨 · `[정적]` 뱃지 · fallback 값 input · 펼침 · 삭제 | "정적 값" 박스 → 외형 → 비교/변화량 (옵션) |

- 변화량 (`item.delta`) 은 어디서도 "필수" 같지 않음. expanded 안에서 접혀 있고, 비우면 카드에 아무 줄도 안 그려짐
- StatsComponent 렌더 조건은 그대로 `item.delta && <span>...</span>`

#### 인사 관리 KPI 카드 4개 설정 예시 (도메인 hardcode 0건)

각 카드는 **DB 모드** + `user_info` / `user_dept_role` 테이블 + count 기반. 화면 / 데이터는 인사 관리 예시지만 구현은 범용:

| 카드 라벨 | DB / 정적 | 테이블 | 집계 | 컬럼 | 필터 |
|---|---|---|---|---|---|
| 전체 사원 | DB | `user_info` | 건수 | (생략) | (없음) — 테이블 전체 row 카운트 |
| 재직 중 | DB | `user_info` | 건수 | (생략) | `status = "재직"` (value_type=static) |
| 휴직 중 | DB | `user_info` | 건수 | (생략) | `status = "휴직"` (value_type=static) |
| 겸직 현황 | DB | `user_dept_role` | 고유 | `user_id` | (없음) — distinctCount(user_id) |

설정 흐름:
1. 컨테이너 / 탭 위에 `stats` drop → 우측 InvStatsConfigPanel 진입
2. ④ 항목 list 의 `[+ 추가]` 로 항목 1개 생성 → 라벨 "전체 사원" 입력
3. collapsed row 의 `[정적]` 뱃지 클릭 → `[DB]` 로 전환 (primary 색)
4. row 펼치기 → DB 바인딩 박스에서 테이블 = `user_info`, 집계 = `건수`
5. 항목 추가 후 "재직 중" 라벨 + DB 뱃지 + 펼침 → 같은 테이블 / 집계 + 필터 `+ 추가` → `column="status", operator="=", value_type=고정값, value="재직"`
6. "휴직 중" 동일, `value="휴직"`
7. "겸직 현황" → DB 모드 + `user_dept_role` + 집계 = `고유` + 컬럼 = `user_id`. 필터 없음 → distinctCount
8. 어떤 카드에도 정적 값 / 변화량 강제 노출 없음. 디자인 모드에서는 fetch 안 함 → "—" 표시. 운영 모드 진입 시 실 DB 값으로 자동 채워짐

다른 도메인 예 (영업 / 재고):
- `매출 합계` = sum / `orders.amount` / filter `status="completed"`
- `재고 부족` = count / `stock` / filter `quantity < 10`

같은 패턴 — 도메인 hardcode 0건.

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` | 0 matches |
| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches |
| `tsc --noEmit` G.4.1 변경 파일 한정 (`stats/`, `_shared/`) | 0 new errors |
| `tsc --noEmit` 전체 | 3376 (G.3.1 종료와 동일 — G.4.1 신규 0건) |

#### Codex verification follow-up

검증 중 실제 생성 경로의 잔여 `0` 기본값을 추가 보정:

- `frontend/lib/registry/components/stats/index.ts` 의 `StatsDefinition.default_config.items` 에 남아 있던 `value: 0` 제거. 새 `stats` 컴포넌트를 드롭할 때부터 강제 0 값이 들어가지 않는다.
- 기본 아이콘은 문자열로 보이는 이모지 대신 lucide 이름 (`BarChart3`, `TrendingUp`, `DollarSign`) 으로 정리. `StatsComponent.renderIcon` 경로에서 SVG 로 표시된다.

#### Remaining limitations

1. **컬럼 자동완성 dropdown 미지원** — DB 모드의 `컬럼` 입력은 여전히 free text. stats / chart / cardList / groupedTable 공통 — `tableManagementApi.getColumnList` dropdown 일괄 도입은 별도 phase
2. **`distinctCount` 컬럼 미설정 inline 안내** — 현재는 백엔드 400 으로만 알려줌. config panel 측 inline error highlight 는 후속
3. **DB 모드 fallback 값 UI** — DB 모드의 정적 fallback (운영 모드 fetch 실패 시 보임) 입력은 현재 collapsed 에서 숨김. 필요해지면 expanded DB 바인딩 박스 안에 추가 노출 검토
4. **delta 자동 계산** — `delta` 는 여전히 수동 입력. "DB 에서 전월 대비 자동 계산" 같은 시스템은 별도 phase
5. **i18n** — `"DB"`, `"정적"`, `"비교 / 변화량"` 한국어 fixed. 다국어는 전역 i18n phase 와 함께

---

### Phase G.4.2 Progress

작업일: 2026-05-14. 브랜치 `gbpark-node`.

#### 목적

G.4.1 의 DB/정적 두-모드 토글 UX 도 여전히 잘못된 멘탈 모델. stats 는 **KPI / 집계** 컴포넌트이므로 "정적 값 카드 vs DB 카드" 이분법이 부적절. G.4.2 는 stats 의 편집 UX 와 default 를 **완전히 DB-first** 로 재설계. fallback value / 변화량은 "고급" 섹션 안의 보조 옵션으로만 남김.

#### Changed files

Frontend (Next / TS):
- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` — 전면 재작성
  - `ModeBadge` (DB / 정적 토글) 완전 제거
  - collapsed KPI row: 번호 · 색점 · 라벨 · **테이블 inline select** · **집계 inline select** · **필터 개수 뱃지** · 펼침 · 삭제
  - 테이블 미선택 시 inline select 가 amber 보더 + tooltip "테이블 미설정"
  - expanded: flat CP rows. `SectionLabel` + `CPRow` 만, 카드-속-카드 X
    - **데이터** (테이블 / 집계 / 컬럼 when needed)
    - **필터** (`OptionFilterRow` 리스트)
    - **외형** (아이콘 / 색)
    - **고급** (collapsible, 기본 닫힘): `Fallback 값` + `변화량` + `변화 방향`
  - `addItem` default = `{ label: "항목 N", dataSource: { aggregation: "count" } }`
  - 색상 프리셋 자동 생성도 동일 DB-first
- `frontend/lib/registry/components/stats/index.ts` — `DEFAULT_CONFIG.items` 도 DB-first placeholder 로 변경. `value` 박지 않음. 각 항목이 `dataSource: { aggregation: "count" }` 로 시작. 주석 wording 도 "DB 바인딩" 으로 정리
- `frontend/lib/registry/components/stats/StatsComponent.tsx` — runtime default items 도 동일 DB-first. fallback 의미 유지 (G.4.1 에서 한 displayValue 정리 그대로)

Backend / 다른 컴포넌트 변경 없음.
Documentation: 본 섹션 추가.

#### DB-first stats 의미 (정리)

**모드 개념 제거**. stats 는 항상 KPI 항목으로 동작:
- 모든 새 항목은 `dataSource: { aggregation: "count" }` 로 시작
- 사용자가 collapsed row 에서 테이블을 inline 으로 골라 즉시 집계 활성화
- 디자인 모드 / 테이블 미설정 / fetch 미완 시 → 카드에 `—` 표시
- 운영 모드 fetch 성공 → 실 DB 집계 값 표시
- 옛 row (`dataSource` 없이 `value` 만 가진 legacy data) 도 backward-compat 으로 그대로 값 표시. 새 row 는 그 길로 생성 안 됨
- `value` 는 "Fallback 값" 으로 의미가 좁아짐 — 고급 섹션 안에서만 입력 가능. 디자인 모드 미리보기 / API 실패 안전망 용
- `delta` 도 고급 안에서만 입력. 비우면 카드에 안 그려짐. 자동 계산은 미구현 — 별도 phase

**collapsed KPI row 한 줄 메타**:

```
[#1]  [●]  [라벨...]      [테이블 ▼]   [건수 ▼]   [⌽3]   [▾]   [🗑]
            ↑                                       ↑
            라벨 input                               필터 3개 뱃지 (있을 때 primary)
```

#### 인사 관리 KPI 4개 설정 예시 (DB-first 그대로)

새 stats 컴포넌트 drop 후, ④ KPI 항목의 첫 row 가 이미 DB placeholder. 라벨만 바꾸고 collapsed inline 으로 테이블 / 집계 선택:

| 카드 | 테이블 (collapsed inline) | 집계 (collapsed inline) | 컬럼 | 필터 (expanded) |
|---|---|---|---|---|
| 전체 사원 | `user_info` | 건수 | — | 없음 |
| 재직 중 | `user_info` | 건수 | — | `status = "재직"` |
| 휴직 중 | `user_info` | 건수 | — | `status = "휴직"` |
| 겸직 현황 | `user_dept_role` | 고유 | `user_id` | 없음 |

설정 흐름:
1. `stats` drop → ④ 의 첫 row 라벨 "전체 사원" 입력 → row 의 테이블 셀렉터에서 `user_info`. 집계는 이미 `건수`. 끝
2. `[+ 추가]` 로 row 추가 → 라벨 "재직 중" → 테이블 `user_info` → 펼침 → 필터 `+ 추가` → `column="status", op="=", value_type=고정값, value="재직"`
3. "휴직 중" 동일, `value="휴직"`
4. "겸직 현황" → 라벨 → 테이블 `user_dept_role` → 집계 `고유` 선택 → row 펼침에 자동으로 "컬럼" CPRow 노출 → `user_id` 입력
5. 모든 카드에 정적 값 / 변화량 강제 노출 0건. 디자인 모드 표시 = `—`, 운영 모드 = 실 DB 값

다른 도메인 (영업 / 재고 / 고객) 동일 패턴 — 도메인 hardcode 0건:
- `총 매출` = `orders` / 합계 / `amount` / filter `status="완료"`
- `평균 납기` = `purchase` / 평균 / `lead_time`
- `고객 수` = `customers` / 고유 / `customer_id`

#### Verification

| 항목 | 결과 |
|---|---|
| `git diff --check` | PASS |
| `git diff --cached --check` | PASS |
| `rg "value: 0\|value ?? 0\|정적 값\|[정적]\|ModeBadge"` (stats 폴더) | 0 matches |
| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` | 0 matches |
| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` | 0 matches |
| `rg 'componentType: "v2-file-upload"\|...'` | 0 matches |
| `tsc --noEmit` G.4.2 변경 파일 한정 (`stats/`, `_shared/`) | 0 new errors |
| `tsc --noEmit` 전체 | 3376 (G.4.1 종료와 동일 — G.4.2 신규 0건) |

#### Remaining limitations

1. **컬럼 자동완성 dropdown** — stats 의 `컬럼` 입력은 여전히 free text. chart / cardList / groupedTable 도 동일, `tableManagementApi.getColumnList` 일괄 도입은 별도 phase
2. **`distinctCount` 컬럼 미설정 inline 검증** — 현재 백엔드 400 으로만 알려줌. config panel inline error highlight 는 후속
3. **delta 자동 계산** — `delta` 는 여전히 수동 입력 ("고급" 섹션 안)
4. **legacy 항목 backward compat** — `dataSource` 없이 `value` 만 가진 옛 row 는 그대로 값 표시되지만, 새 컴포넌트에서는 더 이상 생성 경로 없음. 옛 데이터 마이그레이션은 별도 작업 (필요 시)
5. **i18n** — `"테이블"`, `"건수"`, `"고급"` 등 한국어 fixed. 다국어는 별도
