중간 세이브

This commit is contained in:
2026-04-10 13:33:37 +09:00
parent c6e81c4520
commit 9c36191ebf
97 changed files with 13844 additions and 482 deletions
@@ -0,0 +1,634 @@
# Phase 1: DB 메타 읽기 → FieldConfig 변환
> **목적**: PostgreSQL 테이블의 스키마(컬럼명, 타입, PK/FK, nullable 등)를 읽어서 INVYONE의 FieldConfig[] 배열로 변환하는 파이프라인 구축
> **전제 조건**: 없음 (최초 단계)
> **산출물**: 테이블명을 주면 FieldConfig[]을 반환하는 백엔드 API + 프론트엔드 타입/API 클라이언트
> **다음 단계**: Phase 2가 이 FieldConfig[]을 받아서 테이블/폼/검색 컴포넌트로 렌더링
---
## 1. 핵심 개념
INVYONE의 모든 UI는 **FieldConfig**라는 단일 규격으로 동작한다. FieldConfig는 DB 테이블의 컬럼 메타데이터를 UI가 소비할 수 있는 형태로 변환한 것이다.
```
PostgreSQL information_schema + table_type_columns (기존)
→ Java Service에서 변환
→ FieldConfig[] JSON 반환
→ 프론트엔드가 이걸 받아서 렌더링
```
---
## 2. 현재 존재하는 것
### 2.1 백엔드 (backend-spring)
| 파일 | 역할 | 활용 |
|---|---|---|
| `TableManagementController.java` | 테이블/컬럼 CRUD API | **확장 대상** — FieldConfig 변환 API 추가 |
| `TableManagementService.java` | 테이블 메타 조회 서비스 | **확장 대상** — 변환 로직 추가 |
| `mapper/TableManagementMapper.xml` | MyBatis SQL | **확장 대상** — 조회 쿼리 추가 |
| `DataController.java` | 범용 데이터 CRUD | 참고용 — 나중에 Phase 2에서 활용 |
### 2.2 DB 테이블 (이미 존재)
**`table_type_columns`** — VEX에서 가져온 컬럼 메타데이터 테이블:
- `table_name`, `column_name`, `input_type`, `detail_settings` (JSONB)
- `is_nullable`, `display_order`, `company_code`
- 회사별 오버라이드 지원 (`company_code = '*'`이면 글로벌)
**PostgreSQL `information_schema.columns`** — DB 원본 스키마:
- `table_name`, `column_name`, `data_type`, `is_nullable`, `column_default`
- `character_maximum_length`, `ordinal_position`
**PostgreSQL `information_schema.table_constraints` + `key_column_usage`** — PK/FK 정보
### 2.3 프론트엔드
| 파일 | 역할 |
|---|---|
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (이미 완성)** — 이 파일의 `FieldConfig` 인터페이스가 진실의 원천 |
| `frontend/lib/api/tableSchema.ts` | 테이블 스키마 API 클라이언트 (기존 VEX용, 참고) |
---
## 3. 상세 설계
### 3.1 FieldConfig 타입 (이미 정의됨 — frontend/types/invyone-component.ts)
```typescript
interface FieldConfig {
// 식별
column: string; // DB 컬럼명
label: string; // 화면 표시 라벨
// 타입
type: FieldType; // 'text' | 'number' | 'date' | 'datetime' | 'select' | 'entity' | 'checkbox' | 'textarea' | 'file' | 'code'
// 표시
visible: boolean;
order: number;
width?: number;
align?: 'left' | 'center' | 'right';
// 입력
required: boolean;
editable: boolean;
defaultValue?: unknown;
placeholder?: string;
// 타입별 확장
options?: string[]; // select용
ref?: FieldRef; // entity FK 참조
format?: string; // 포맷 문자열
computed?: string; // 자동 계산 수식
// 메타
pk?: boolean;
system?: boolean; // company_code 같은 시스템 필드
searchable?: boolean;
sortable?: boolean;
}
```
### 3.2 PostgreSQL → FieldType 매핑 규칙
DB의 `data_type`을 FieldType으로 변환하는 규칙:
```
PostgreSQL data_type → FieldType
─────────────────────────────────────────
character varying, varchar → 'text'
text → 'textarea'
integer, bigint, smallint → 'number'
numeric, decimal, real, double→ 'number'
boolean → 'checkbox'
date → 'date'
timestamp, timestamptz → 'datetime'
jsonb, json → 'textarea'
bytea → 'file'
(그 외) → 'text' (기본값)
```
**단, `table_type_columns.input_type`이 있으면 그것이 우선한다.**
`table_type_columns.input_type` → FieldType 매핑:
```
input_type → FieldType
────────────────────────────
text → 'text'
number → 'number'
date → 'date'
datetime → 'datetime'
select → 'select'
entity → 'entity'
checkbox → 'checkbox'
boolean → 'checkbox'
textarea → 'textarea'
text_area → 'textarea'
file → 'file'
code → 'code'
numbering → 'code'
category → 'select'
decimal → 'number'
email → 'text'
password → 'text'
tel → 'text'
(그 외) → 'text'
```
### 3.3 변환 로직 상세
입력: `table_name` (String), `company_code` (String)
```
Step 1: information_schema.columns에서 해당 테이블의 모든 컬럼 조회
Step 2: information_schema.table_constraints + key_column_usage에서 PK 컬럼 목록 조회
Step 3: table_type_columns에서 해당 테이블의 커스텀 메타 조회 (company_code 우선순위: 해당 회사 > '*')
→ input_type='entity'인 컬럼의 detail_settings JSON에서 FK 참조 정보 추출
→ referenceTable, referenceColumn, displayColumn
Step 4: Step 1~3 병합하여 FieldConfig[] 생성
```
**★ FK는 DB 제약조건(FOREIGN KEY)이 아닌 `table_type_columns.detail_settings` JSON으로 관리.**
VEX는 비즈니스 테이블에 DB 레벨 FK 제약조건을 걸지 않는다. 관계 정보는 전부 메타데이터 테이블에서 관리한다.
이유: 유연성 (마이그레이션 없이 관계 변경), 멀티테넌시 (회사별 관계 설정), 데이터 이관 시 제약조건 우회 가능.
#### Step 5 병합 규칙:
```
FieldConfig.column = information_schema.column_name
FieldConfig.label = table_type_columns.column_label (있으면) || column_name을 한글화 시도 (없으면 그대로)
FieldConfig.type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
FieldConfig.visible = true (기본값, table_type_columns에서 오버라이드 가능)
FieldConfig.order = table_type_columns.display_order (있으면) || information_schema.ordinal_position
FieldConfig.required = (is_nullable = 'NO') 이고 (column_default가 없으면) true
FieldConfig.editable = PK가 아니고 system이 아니면 true
FieldConfig.pk = PK 컬럼이면 true
FieldConfig.system = column_name이 'company_code', 'created_by', 'created_date', 'updated_by', 'updated_date', 'is_active' 중 하나면 true
FieldConfig.searchable = table_type_columns에서 지정 (없으면 text/select/entity/date만 true)
FieldConfig.sortable = true (기본값)
FieldConfig.ref = table_type_columns.input_type='entity'이면 detail_settings에서 { table, value_column, display_column } 추출
FieldConfig.options = table_type_columns.detail_settings에서 options 추출 (select 타입)
FieldConfig.format = number이면 '#,##0', date이면 'YYYY-MM-DD', datetime이면 'YYYY-MM-DD HH:mm'
FieldConfig.width = type에 따라 기본값: text=150, number=100, date=120, select=130, entity=180
FieldConfig.align = number이면 'right', 나머지 'left'
```
### 3.4 백엔드 API 설계
#### 3.4.1 신규 API 엔드포인트
**`GET /api/meta/tables`** — 접근 가능한 테이블 목록
```
Request: (없음, JWT에서 company_code 추출)
Response:
{
"success": true,
"data": [
{
"table_name": "order_management_test",
"table_label": "수주관리",
"column_count": 13,
"has_custom_meta": true
},
...
]
}
```
**`GET /api/meta/tables/{tableName}/fields`** — 특정 테이블의 FieldConfig[] 반환
```
Request: GET /api/meta/tables/order_management_test/fields
Headers: Authorization: Bearer {jwt}
Response (★ 백엔드 응답은 전부 snake_case, 프론트에서 camelCase 변환):
{
"success": true,
"data": {
"table_name": "order_management_test",
"table_label": "수주관리",
"primary_key": "order_no",
"fields": [
{
"column": "order_no",
"label": "수주번호",
"type": "code",
"visible": true,
"order": 1,
"width": 120,
"align": "left",
"required": true,
"editable": false,
"pk": true,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": null,
"ref": null,
"computed": null
},
{
"column": "order_date",
"label": "수주일",
"type": "date",
"visible": true,
"order": 2,
"width": 120,
"align": "left",
"required": true,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": "YYYY-MM-DD",
"options": null,
"ref": null,
"computed": null
},
{
"column": "customer",
"label": "거래처",
"type": "entity",
"visible": true,
"order": 3,
"width": 180,
"align": "left",
"required": true,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": null,
"ref": {
"table": "customer_mng",
"value_column": "customer_code",
"display_column": "customer_name",
"search_columns": ["customer_name", "biz_number"]
},
"computed": null
},
{
"column": "amount",
"label": "금액",
"type": "number",
"visible": true,
"order": 7,
"width": 100,
"align": "right",
"required": false,
"editable": false,
"pk": false,
"system": false,
"searchable": false,
"sortable": true,
"format": "#,##0",
"options": null,
"ref": null,
"computed": "quantity * unit_price"
},
{
"column": "status",
"label": "상태",
"type": "select",
"visible": true,
"order": 9,
"width": 100,
"align": "center",
"required": false,
"editable": true,
"pk": false,
"system": false,
"searchable": true,
"sortable": true,
"format": null,
"options": ["임시저장", "확정", "완료", "취소"],
"ref": null,
"computed": null
},
{
"column": "company_code",
"label": "회사코드",
"type": "text",
"visible": false,
"order": 99,
"required": false,
"editable": false,
"pk": false,
"system": true,
"searchable": false,
"sortable": false
}
]
}
}
```
**★ 관계 정보 2소스 책임 분리 (B안 확정):**
| 소스 | 용도 | 사용 Phase |
|---|---|---|
| `table_type_columns.detail_settings` | **필드 레벨 참조** — FieldConfig.ref, entity picker, 폼/검색/테이블 렌더링 | Phase 1 (`getTableFields`) |
| `table_relationships` | **테이블 간 관계** — 제어 모드, 데이터플로우, 업무적 연결 (one-to-many 등) | Phase 5 (`getMetaRelations`) |
- Phase 1의 `getTableFields()``table_type_columns`만 읽는다 (필드 참조 원천)
- Phase 5의 관계 그래프는 `table_relationships`를 읽는다 (업무 관계 원천)
- 제어 모드에서 두 소스가 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
따라서 **이 Phase 1에서는 relations API를 만들지 않는다.** `getTableFields()``table_type_columns`에서 entity 참조(ref)를 추출하는 것까지만 책임진다. `table_relationships` 기반 관계 API는 Phase 5에서 구현한다.
#### 3.4.2 구현할 Java 파일 (★ 파이프라인 규칙 준수)
**★ 덕일 스타일 3레이어. Mapper Interface 금지. 파일명 1:1 매칭.**
| 파일 | 경로 | 비고 |
|---|---|---|
| `MetaController.java` | `controller/` | `/api/meta/*` |
| `MetaService.java` | `service/` | `extends BaseService` |
| `meta.xml` | `resources/mapper/` | `namespace="meta"` (소문자, Mapper 안 붙임) |
**MetaController.java:**
```java
@RestController
@RequestMapping("/api/meta")
@Slf4j
public class MetaController {
@Autowired
private MetaService metaService;
@GetMapping("/tables")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMetaTableList(
@RequestAttribute("companyCode") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaTableList(params)));
}
@GetMapping("/tables/{tableName}/fields")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMetaFields(
@PathVariable String tableName,
@RequestAttribute("companyCode") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(metaService.getMetaFields(params)));
}
// ★ relations API는 Phase 1에서 안 만듦
// table_relationships 기반 관계 조회는 Phase 5 (제어 모드)에서 구현
// Phase 1은 getTableFields()의 FieldConfig.ref (entity 참조)까지만 책임
}
```
**MetaService.java:**
```java
@Service
@Slf4j
public class MetaService extends BaseService {
@Autowired
private CommonService commonService;
/** 테이블 목록 */
public List<Map<String, Object>> getMetaTableList(Map<String, Object> params) {
return sqlSession.selectList("meta.getMetaTableList", params);
}
/** 특정 테이블의 FieldConfig[] 반환 */
public Map<String, Object> getMetaFields(Map<String, Object> params) {
List<Map<String, Object>> schemaCols = sqlSession.selectList("meta.getSchemaColumns", params);
List<String> pks = sqlSession.selectList("meta.getPrimaryKeys", params);
List<Map<String, Object>> customMeta = sqlSession.selectList("meta.getCustomMeta", params);
// ★ FK는 DB 제약조건이 아닌 customMeta(table_type_columns)의 detail_settings에서 추출
// input_type='entity'인 컬럼의 detail_settings JSON → referenceTable/referenceColumn/displayColumn
List<Map<String, Object>> fields = buildFieldConfigs(schemaCols, pks, customMeta);
Map<String, Object> result = new HashMap<>();
result.put("table_name", params.get("table_name"));
result.put("fields", fields);
return result;
}
// ★ relations 조회는 Phase 5에서 table_relationships 기반으로 구현
// Phase 1은 getMetaFields()의 FieldConfig.ref (entity 참조)까지만 책임
/** 병합하여 FieldConfig Map 리스트 생성 (상세 로직은 Section 3.3 참고) */
private List<Map<String, Object>> buildFieldConfigs(
List<Map<String, Object>> schemaCols,
List<String> pks,
List<Map<String, Object>> customMeta
) {
// column, label, type, visible, order, required, editable, pk, system 등
// customMeta가 있으면 우선 적용
// ★ input_type='entity'이면 detail_settings JSON 파싱 → ref Map 생성
// ObjectMapper로 detail_settings 파싱 → referenceTable, referenceColumn, displayColumn 추출
// system 컬럼은 visible=false, system=true
}
}
```
#### 3.4.3 MyBatis SQL — `meta.xml` (★ 덕일 스타일)
**★ 파일명: `meta.xml` (소문자, Mapper 안 붙임)**
**★ namespace: `meta`**
**★ SQL 키워드/테이블/컬럼: UPPER_SNAKE, #{파라미터}: snake_case**
**★ SELECT 쉼표 앞에, OGNL test 바깥 작은따옴표**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="meta">
<!-- ═══ 테이블 목록 (public 스키마, 시스템 테이블 제외) ═══ -->
<select id="getMetaTableList" parameterType="map" resultType="map">
SELECT
T.TABLE_NAME
, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS C
WHERE C.TABLE_SCHEMA = 'public' AND C.TABLE_NAME = T.TABLE_NAME) AS COLUMN_COUNT
, CASE WHEN EXISTS(
SELECT 1 FROM TABLE_TYPE_COLUMNS TTC
WHERE TTC.TABLE_NAME = T.TABLE_NAME
<if test='company_code != null and company_code != "*"'>
AND (TTC.COMPANY_CODE = #{company_code} OR TTC.COMPANY_CODE = '*')
</if>
) THEN true ELSE false END AS HAS_CUSTOM_META
FROM INFORMATION_SCHEMA.TABLES T
WHERE T.TABLE_SCHEMA = 'public'
AND T.TABLE_TYPE = 'BASE TABLE'
AND T.TABLE_NAME NOT LIKE 'pg_%'
AND T.TABLE_NAME NOT IN ('spatial_ref_sys')
ORDER BY T.TABLE_NAME
</select>
<!-- ═══ 특정 테이블의 컬럼 정보 ═══ -->
<select id="getSchemaColumns" parameterType="map" resultType="map">
SELECT
COLUMN_NAME
, DATA_TYPE
, IS_NULLABLE
, COLUMN_DEFAULT
, CHARACTER_MAXIMUM_LENGTH
, ORDINAL_POSITION
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'public'
AND TABLE_NAME = #{table_name}
ORDER BY ORDINAL_POSITION
</select>
<!-- ═══ PK 컬럼 목록 ═══ -->
<select id="getPrimaryKeys" parameterType="map" resultType="string">
SELECT KCU.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU
ON TC.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME
AND TC.TABLE_SCHEMA = KCU.TABLE_SCHEMA
WHERE TC.CONSTRAINT_TYPE = 'PRIMARY KEY'
AND TC.TABLE_SCHEMA = 'public'
AND TC.TABLE_NAME = #{table_name}
</select>
<!-- ═══ 관계 조회 (★ DB FK 제약조건이 아닌 table_type_columns 메타 기반) ═══ -->
<select id="getMetaRelations" parameterType="map" resultType="map">
SELECT
TABLE_NAME AS FROM_TABLE
, COLUMN_NAME AS FROM_COLUMN
, INPUT_TYPE
, DETAIL_SETTINGS
FROM TABLE_TYPE_COLUMNS
WHERE TABLE_NAME = #{table_name}
AND INPUT_TYPE IN ('entity', 'category')
AND DETAIL_SETTINGS IS NOT NULL
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
</select>
<!--
★ detail_settings JSON에서 참조 정보 추출 (Java Service에서 파싱):
{
"referenceTable": "partner_info",
"referenceColumn": "id",
"displayColumn": "partner_name"
}
→ FieldConfig.ref = { table, value_column, display_column } 로 변환
-->
<!-- ═══ TABLE_TYPE_COLUMNS 커스텀 메타 (회사 우선, '*' 폴백) ═══ -->
<select id="getCustomMeta" parameterType="map" resultType="map">
SELECT DISTINCT ON (COLUMN_NAME)
COLUMN_NAME
, INPUT_TYPE
, DETAIL_SETTINGS
, DISPLAY_ORDER
, IS_NULLABLE
, COMPANY_CODE
FROM TABLE_TYPE_COLUMNS
WHERE TABLE_NAME = #{table_name}
<if test='company_code != null and company_code != "*"'>
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
</if>
ORDER BY COLUMN_NAME
, CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END
</select>
</mapper>
```
### 3.5 프론트엔드 API 클라이언트
**신규 파일: `frontend/lib/api/meta.ts`**
```typescript
import { client } from './client';
import type { FieldConfig } from '@/types/invyone-component';
/**
* ★ 일반 API는 Record<string, any> — 별도 인터페이스 정의 안 함
* ★ 단, FieldConfig만은 invyone-component.ts에 규격 확정된 예외 타입
* → getTableFields()의 fields 배열은 FieldConfig[]로 캐스팅
*/
/** 접근 가능한 테이블 목록 */
export async function getTableList(): Promise<Record<string, any>[]> {
const res = await client.get('/api/meta/tables');
return res.data.data;
}
/** 특정 테이블의 FieldConfig[] 반환 (★ fields만 FieldConfig[] 타입) */
export async function getTableFields(tableName: string): Promise<{
table_name: string;
fields: FieldConfig[];
[key: string]: any;
}> {
const res = await client.get(`/api/meta/tables/${tableName}/fields`);
return res.data.data;
}
// ★ relations API는 Phase 5에서 구현 (table_relationships 기반)
// Phase 1은 getTableFields()의 FieldConfig.ref까지만 책임
```
---
## 4. 시스템 필드 판별 규칙
다음 컬럼명은 자동으로 `system: true`로 설정하고, `visible: false`, `editable: false`로 만든다:
```
company_code, created_by, created_date, updated_by, updated_date,
is_active, deleted_date, deleted_by, writer, write_date
```
---
## 5. 참고 파일
| 파일 | 용도 |
|---|---|
| `frontend/types/invyone-component.ts` | **FieldConfig 타입 정의 (진실의 원천)** — 이 파일의 인터페이스와 정확히 일치하는 JSON을 반환해야 함 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 — 변환 규칙의 근거 |
| `notes/gbpark/2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 — FieldType별 렌더링 계약 |
| `backend-spring/src/main/java/com/erp/service/TableManagementService.java` | 기존 테이블 관리 서비스 — 참고용 (패턴) |
| `backend-spring/src/main/resources/mapper/TableManagementMapper.xml` | 기존 MyBatis 매퍼 — SQL 패턴 참고 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 테이블 메타 데이터 — `AB_TABLE_FIELDS` 객체가 FieldConfig의 mockup 버전 |
---
## 6. 완료 기준
1. **`GET /api/meta/tables`** 호출 시 DB의 public 스키마 테이블 목록이 반환된다
2. **`GET /api/meta/tables/{tableName}/fields`** 호출 시:
- `FieldConfig[]` 형태의 JSON이 반환된다
- `frontend/types/invyone-component.ts``FieldConfig` 인터페이스와 필드명이 정확히 일치한다
- PK 컬럼에 `pk: true`가 설정된다
- system 컬럼(company_code 등)에 `system: true`, `visible: false`가 설정된다
- `table_type_columns`에 커스텀 메타가 있으면 그것이 우선 적용된다
- select 타입 필드에 `options[]`가 포함된다
- entity 타입 필드에 `ref` 객체가 포함된다
3. ~~relations API는 Phase 1에서 안 만듦~~ (Phase 5에서 `table_relationships` 기반으로 구현)
4. 프론트엔드 `lib/api/meta.ts`에서 위 API들을 호출하는 함수가 있다
5. 실제 DB 테이블 (예: `order_management_test`, `user_info`)로 테스트하여 합리적인 FieldConfig가 생성됨을 확인
---
## 7. 다음 단계 연결
Phase 2는 이 API가 반환하는 `FieldConfig[]`을 받아서:
- **테이블 컴포넌트**: FieldConfig[]을 컬럼으로 렌더링
- **폼 컴포넌트**: FieldConfig[]을 입력 필드로 렌더링
- **검색 컴포넌트**: FieldConfig[]을 검색 조건으로 렌더링
Phase 2의 컴포넌트들은 `getTableFields(tableName)`을 호출하여 FieldConfig[]을 가져온 뒤 렌더링한다.
@@ -0,0 +1,298 @@
# Phase 1 구현 작업기록 — DB 메타 → FieldConfig 변환
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase1-db-meta-to-fieldconfig.md`
> **상태**: 구현 완료 + DB 실테스트 통과
---
## 1. 생성된 파일 (4개)
### 1.1 `backend-spring/src/main/resources/mapper/meta.xml`
MyBatis XML 매퍼. namespace=`meta`, 쿼리 5개:
| 쿼리 ID | 용도 | 반환 |
|---|---|---|
| `getMetaTableList` | public 스키마 테이블 목록 | table_name, table_label, column_count, has_custom_meta |
| `getSchemaColumns` | information_schema.columns 전체 컬럼 | column_name, data_type, is_nullable, column_default, ordinal_position |
| `getPrimaryKeys` | PK 컬럼명 목록 | string (column_name) |
| `getCustomMeta` | table_type_columns 커스텀 메타 (회사 우선순위) | column_name, column_label, input_type, detail_settings, reference_table 등 |
| `getTableLabel` | TABLE_LABELS에서 라벨 단건 | string (table_label) |
**회사 우선순위 처리**: `getCustomMeta`에서 `DISTINCT ON (COLUMN_NAME)` + `CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END` 정렬로 회사별 메타가 '*'(글로벌)보다 우선.
---
### 1.2 `backend-spring/src/main/java/com/erp/service/MetaService.java`
핵심 변환 로직. 덕일 스타일 준수 — extends BaseService, @Autowired CommonService, sqlSession 직접 호출.
#### 주요 메서드
| 메서드 | 역할 |
|---|---|
| `getMetaTableList(params)` | 테이블 목록 pass-through |
| `getMetaFields(params)` | 4개 쿼리 오케스트레이션 → buildFieldConfigs 호출 → 결과 조립 |
| `buildFieldConfigs(schemaCols, pks, customMeta)` | **핵심** — 3소스 병합하여 FieldConfig Map 리스트 생성 |
| `mapDataTypeToFieldType(dataType)` | PostgreSQL data_type → FieldType 변환 (12개 매핑) |
| `mapInputTypeToFieldType(inputType)` | table_type_columns.input_type → FieldType 변환 (15개 매핑) |
| `buildFieldRef(meta, detailSettings)` | entity 타입의 ref 객체 빌드 (top-level 컬럼 우선 → detail_settings 폴백) |
| `extractOptions(detailSettings)` | select 타입의 options 추출 (`string[]``[{value,label}]` 둘 다 처리) |
| `parseDetailSettings(meta)` | detail_settings JSONB → Map 파싱 (ObjectMapper) |
#### buildFieldConfigs 병합 규칙 (필드별)
```
column = information_schema.column_name
label = table_type_columns.column_label (있으면) || column_name
type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
visible = system이면 무조건 false, 아니면 is_visible
order = display_order > 0이면 우선 || ordinal_position
required = ★ 앱 레벨 메타(table_type_columns.is_nullable) 우선, 없으면 DB 스키마 폴백
editable = !PK && !system && type!='code'
pk = information_schema PK 제약조건
system = SYSTEM_FIELDS 목록에 포함 여부
searchable = !system && (text|select|entity|date|code)
sortable = !system
format = number→'#,##0', date→'YYYY-MM-DD', datetime→'YYYY-MM-DD HH:mm'
width = type별 기본값 (number:100, date:120, entity:180 등)
align = number→'right', 나머지→'left'
options = detail_settings.options (string[] 또는 [{value,label}] → label 추출)
ref = entity일 때 reference_table/column/display_column (top-level 우선 → detail_settings 폴백)
computed = detail_settings.computed (있으면 editable=false 강제)
```
#### required 판정 로직 (★ 수동 수정 반영)
```java
// ★ 앱 레벨 메타 우선, DB 스키마 폴백
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
required = "NO".equalsIgnoreCase(metaNullable);
} else {
// 없으면 information_schema 폴백
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
}
```
원래 구현: information_schema.is_nullable + column_default 기반.
수정 이유: VEX에서 앱 레벨 메타(table_type_columns.IS_NULLABLE)가 DB 스키마보다 우선하는 패턴. 관리자가 설정한 필수 여부가 DB 제약조건보다 비즈니스적으로 정확함.
#### system 필드 목록
```
company_code, created_by, created_date, updated_by, updated_date,
is_active, deleted_date, deleted_by, writer, write_date
```
→ 자동으로 `visible: false`, `editable: false`, `required: false`, `searchable: false`, `sortable: false`
#### 타입 매핑 테이블
**PostgreSQL data_type → FieldType:**
| data_type | FieldType |
|---|---|
| character varying, varchar | text |
| text | textarea |
| integer, bigint, smallint | number |
| numeric, decimal, real, double precision | number |
| boolean | checkbox |
| date | date |
| timestamp (with/without tz) | datetime |
| jsonb, json | textarea |
| bytea | file |
| 기타 | text |
**input_type → FieldType (custom meta 우선):**
| input_type | FieldType |
|---|---|
| text, email, password, tel | text |
| number, decimal | number |
| date | date |
| datetime | datetime |
| select, category | select |
| entity | entity |
| checkbox, boolean | checkbox |
| textarea, text_area | textarea |
| file | file |
| code, numbering | code |
| 기타 | text |
#### entity ref 빌드 로직
```
1차: top-level 컬럼 (REFERENCE_TABLE, REFERENCE_COLUMN, DISPLAY_COLUMN)
2차: detail_settings JSON 폴백 (referenceTable, referenceColumn, displayColumn)
→ table이 없으면 ref=null
→ value_column 기본값 "id", display_column 기본값 = value_column
→ search_columns는 detail_settings에서만 추출
```
#### select options 추출 로직
```
detail_settings.options 필드에서 추출
- string[] 형식: ["대기", "확정", ...] → 그대로 반환
- [{value, label}] 형식: [{value:"PENDING", label:"대기"}, ...] → label만 추출
- options 없으면 null (category 타입은 common code 연동 필요 — Phase 1 범위 밖)
```
---
### 1.3 `backend-spring/src/main/java/com/erp/controller/MetaController.java`
REST 컨트롤러. 2개 엔드포인트:
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
| `GET /api/meta/tables` | `getMetaTableList` | 접근 가능한 테이블 목록 |
| `GET /api/meta/tables/{tableName}/fields` | `getMetaFields` | 특정 테이블의 FieldConfig[] 반환 |
- `@RequestAttribute("company_code")` — JWT 필터에서 주입 (snake_case, 기존 패턴 따름)
- `@RequiredArgsConstructor` + `private final MetaService` — 기존 컨트롤러 패턴 따름
- relations API는 Phase 1에서 안 만듦 (Phase 5 담당)
---
### 1.4 `frontend/lib/api/meta.ts`
프론트엔드 API 클라이언트. 2개 함수:
| 함수 | 반환 타입 | 설명 |
|---|---|---|
| `getMetaTableList()` | `Record<string, any>[]` | 테이블 목록 (별도 인터페이스 안 만듦) |
| `getMetaFields(tableName)` | `{table_name, table_label, primary_key, fields: FieldConfig[]}` | FieldConfig 규격 타입 사용 (유일한 예외) |
**snake_case → camelCase 변환 처리:**
- `toFieldConfig(raw)` — 대부분 단일 단어라 변환 불필요, `default_value``defaultValue`만 처리
- `toFieldRef(raw)``value_column``valueColumn`, `display_column``displayColumn`, `search_columns``searchColumns`
- `apiClient` 사용 (baseURL에 `/api` 이미 포함 → `/meta/tables`로 호출)
---
## 2. 덕일 스타일 준수 체크리스트
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | O |
| Mapper Interface 금지 | O — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | O — ApiResponse만 예외 |
| BaseService 상속 | O |
| @Autowired CommonService | O |
| XML 파일명: 소문자, Mapper 안 붙임 | O — `meta.xml` |
| XML namespace: 파일명과 동일 | O — `namespace="meta"` |
| SQL: UPPER_SNAKE | O |
| SELECT 쉼표: 앞에 | O |
| #{파라미터}: snake_case | O |
| OGNL test: 바깥 작은따옴표 | O |
| 프론트 타입: Record<string, any> (FieldConfig만 예외) | O |
---
## 3. 실제 DB 테스트 결과
**테스트 환경**: `test_dev` DB (211.115.91.141:11134)
**테스트 계정**: shkim (SUPER_ADMIN)
**테스트 테이블**: `sales_order_mng` (entity/select/category/number/date 타입 다수 보유)
### 3.1 GET /api/meta/tables
```json
{
"success": true,
"data": [
{"table_name": "approval_definitions", "table_label": "approval_definitions", "column_count": 15, "has_custom_meta": false},
{"table_name": "sales_order_mng", "table_label": "수주관리", "column_count": 44, "has_custom_meta": true},
...
]
}
```
### 3.2 GET /api/meta/tables/sales_order_mng/fields
**검증 결과:**
| 검증 항목 | 결과 | 상세 |
|---|---|---|
| table_label | OK | "수주관리" (TABLE_LABELS에서) |
| primary_key | OK | "id" |
| PK 감지 | OK | id: `pk:true, editable:false` |
| system 필드 | OK | company_code, created_by/date, updated_by/date, writer → `visible:false, system:true` |
| entity + ref | OK | partner_id: `ref:{table:"customer_mng", value_column:"customer_code", display_column:"customer_name"}` |
| entity (top-level 컬럼) | OK | delivery_partner_id: `ref:{table:"delivery_destination", ...}` |
| select + options ({value,label}) | OK | shipping_method: `options:["직접배송","택배","화물","퀵서비스"]` |
| select + options (문자열) | OK | status: `options:["대기","확정","출하","완료","취소"]` |
| custom meta 우선 | OK | id(integer) → table_type_columns에 input_type='text' 설정 → type:"text" |
| date 포맷 | OK | `format:"YYYY-MM-DD"` |
| number 포맷/정렬 | OK | `format:"#,##0", align:"right"` |
| 컴파일 | OK | `./gradlew compileJava` + `./gradlew bootJar` 성공 |
| 프론트 타입체크 | OK | `npx tsc --noEmit` — meta.ts 에러 0개 |
---
## 4. Phase 1 범위 밖 (나중에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| relations API (table_relationships) | Phase 5 | 제어 모드에서 사용 |
| category 타입의 common code 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
| entity ref가 없는 entity 필드 처리 | Phase 2+ | part_code, manager_id 등 — reference_table 미설정 |
| FieldConfig.defaultValue, placeholder 세팅 | Phase 2+ | detail_settings에서 추출 가능하나 현재 미구현 |
| select options의 value/label 분리 저장 | Phase 2+ | 현재 label만 string[]로 반환, value 매핑 필요 시 타입 확장 |
---
## 5. 파일별 코드 요약
### meta.xml (89줄)
```
5개 쿼리:
- getMetaTableList: INFORMATION_SCHEMA.TABLES + TABLE_LABELS + TABLE_TYPE_COLUMNS EXISTS
- getSchemaColumns: INFORMATION_SCHEMA.COLUMNS
- getPrimaryKeys: TABLE_CONSTRAINTS + KEY_COLUMN_USAGE
- getCustomMeta: TABLE_TYPE_COLUMNS (DISTINCT ON + 회사 우선순위)
- getTableLabel: TABLE_LABELS 단건
```
### MetaService.java (375줄)
```
퍼블릭 메서드 2개:
- getMetaTableList(params) → List<Map>
- getMetaFields(params) → Map (table_name, table_label, primary_key, fields[])
프라이빗 메서드 10개:
- buildFieldConfigs: 3소스 병합 (핵심, ~120줄)
- mapDataTypeToFieldType: PG타입→FieldType (12매핑)
- mapInputTypeToFieldType: input_type→FieldType (15매핑)
- buildFieldRef: entity ref 조립 (top-level 우선 → detail_settings 폴백)
- extractOptions: select options 추출 (string[] + {value,label} 대응)
- parseDetailSettings: JSONB→Map (ObjectMapper)
- getDefaultWidth: 타입별 기본 너비
- getDefaultFormat: 타입별 기본 포맷
- str, strFromMap, num: Map 유틸
```
### MetaController.java (45줄)
```
2개 엔드포인트:
- GET /api/meta/tables → getMetaTableList
- GET /api/meta/tables/{tableName}/fields → getMetaFields
```
### meta.ts (86줄)
```
2개 API 함수:
- getMetaTableList() → Record<string, any>[]
- getMetaFields(tableName) → {fields: FieldConfig[], ...}
2개 변환 헬퍼:
- toFieldConfig(raw) → FieldConfig
- toFieldRef(raw) → FieldRef (snake→camel)
```
@@ -0,0 +1,386 @@
# Phase 2: 규격 기반 컴포넌트 — FieldConfig로 테이블/폼/검색 렌더링
> **목적**: FieldConfig[]을 입력으로 받아 테이블/폼/검색 UI를 렌더링하는 React 컴포넌트 구현
> **전제 조건**: Phase 1 완료 (GET /api/meta/tables/{tableName}/fields API가 FieldConfig[]을 반환)
> **산출물**: FieldConfig 기반 3대 핵심 컴포넌트 (FcTable, FcForm, FcSearch) + 보조 컴포넌트 (FcButton, FcButtonBar, FcPagination)
> **다음 단계**: Phase 3에서 이 컴포넌트들을 개발자 빌더 캔버스에 배치
---
## 1. 핵심 원칙
**"같은 FieldConfig가 컴포넌트에 따라 다르게 렌더된다"**
```
FieldConfig { column: 'order_date', label: '수주일', type: 'date' }
→ FcTable에서: <td>2026-04-08</td> (format 적용된 텍스트)
→ FcForm에서: <DatePicker label="수주일" /> (입력 위젯)
→ FcSearch에서: <DateRangePicker /> (범위 검색)
```
이 렌더링 규칙은 `notes/gbpark/2026-04-08-invyone-component-spec.md` Section 2.2에 명시되어 있다. **반드시 이 매핑 테이블을 따를 것.**
---
## 2. 현재 존재하는 것
### 2.1 타입 정의 (이미 완성)
`frontend/types/invyone-component.ts` — FieldConfig, Component, DataPort, Template, 모든 Config 타입이 정의됨. **이 파일의 타입을 그대로 사용한다. 새로 만들지 말 것.**
### 2.2 기존 컴포넌트 (참고용, 직접 사용 금지)
기존 `frontend/lib/registry/components/`에 85개+ 컴포넌트가 있지만, 이것들은 VEX 규격(ColumnConfig 354줄 등)에 맞춰 만들어진 것이므로 **FieldConfig 기반 신규 컴포넌트를 만든다.** 기존 컴포넌트는 코드 패턴 참고용으로만 사용.
### 2.3 UI 라이브러리 (활용)
| 라이브러리 | 용도 |
|---|---|
| Radix UI | 기본 UI 프리미티브 (Dialog, Select, Checkbox 등) |
| TanStack Table v8 | 테이블 렌더링 |
| React Hook Form v7 | 폼 상태 관리 |
| Zod v4 | 폼 유효성 검증 |
| date-fns v4 | 날짜 포맷팅 |
| Lucide React | 아이콘 |
### 2.4 디자인 시스템
v5 Cosmic Glassmorphism (`frontend/styles/v5-layout.css`, `frontend/app/globals.css`). 모든 컴포넌트는 v5 토큰을 따른다. 즉흥 hex/rgb 금지.
---
## 3. 파일 구조
```
frontend/components/fc/ ← FieldConfig 기반 컴포넌트 (fc = FieldConfig)
├── FcTable.tsx ← 데이터 테이블
├── FcForm.tsx ← 입력 폼
├── FcSearch.tsx ← 검색 필터
├── FcButton.tsx ← 단일 버튼
├── FcButtonBar.tsx ← 버튼 그룹
├── FcPagination.tsx ← 페이지네이션
├── fields/ ← FieldType별 렌더러
│ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
│ ├── TextField.tsx
│ ├── NumberField.tsx
│ ├── DateField.tsx
│ ├── DateTimeField.tsx
│ ├── SelectField.tsx
│ ├── EntityField.tsx
│ ├── CheckboxField.tsx
│ ├── TextareaField.tsx
│ ├── FileField.tsx
│ └── CodeField.tsx
├── table/ ← 테이블 전용 셀 렌더러
│ └── CellRenderer.tsx ← FieldType→셀 포맷 디스패처
└── index.ts ← 공개 exports
```
---
## 4. 상세 설계
### 4.1 FieldRenderer — FieldType→위젯 디스패처
모든 폼/검색 입력 필드를 렌더링하는 허브 컴포넌트.
```typescript
// frontend/components/fc/fields/FieldRenderer.tsx
interface FieldRendererProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
mode: 'form' | 'search'; // 폼인지 검색인지에 따라 위젯이 달라짐
disabled?: boolean;
error?: string;
}
/**
* FieldConfig.type을 보고 적절한 입력 위젯을 렌더한다.
*
* mode='form' 이면 폼 렌더링 규칙 적용:
* date → DatePicker(단일), select → Select(단일)
*
* mode='search' 이면 검색 렌더링 규칙 적용:
* date → DateRangePicker(범위), select → MultiSelect(다중)
*/
function FieldRenderer({ field, value, onChange, mode, disabled, error }: FieldRendererProps) {
// field.type에 따라 switch/case로 분기
// 각 케이스에서 fields/ 폴더의 개별 컴포넌트를 렌더
}
```
### 4.2 렌더링 계약 (★ 반드시 이 테이블대로 구현)
| FieldType | FcTable (셀) | FcForm (입력) | FcSearch (검색) |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<input type="text">` | `<input>` (부분 일치) |
| `number` | format 적용 (#,##0) | `<input type="number">` | min~max 범위 입력 2개 |
| `date` | format 적용 (YYYY-MM-DD) | DatePicker (단일) | **DateRangePicker (시작~종료)** |
| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker |
| `select` | 텍스트 그대로 | `<Select>` (단일) | **MultiSelect (다중, 체크박스)** |
| `entity` | ref.displayColumn 값 표시 | 팝업 검색 버튼 + 입력 | 팝업 검색 (단일) |
| `checkbox` | ✓/✗ 아이콘 | `<Checkbox>` | `<Select>` (전체/✓/✗) |
| `textarea` | 말줄임 (...) 40자 | `<textarea>` rows=3 | `<input>` (부분 일치) |
| `file` | 파일명 링크 | 파일 업로드 | — (검색 불가, 렌더 안 함) |
| `code` | 텍스트 그대로 | readonly 표시 | `<input>` (완전 일치) |
### 4.3 FcTable — 데이터 테이블
```typescript
// frontend/components/fc/FcTable.tsx
interface FcTableProps {
fields: FieldConfig[]; // 컬럼 정의
data: Record<string, any>[]; // 행 데이터
config?: TableConfig; // 테이블 설정 (invyone-component.ts)
loading?: boolean;
// ─── DataPort 출력 (콜백) ───
onRowSelect?: (row: Record<string, any>) => void; // selectedRow(row)
onRowsSelect?: (rows: Record<string, any>[]) => void; // selectedRows(rows)
// ─── DataPort 입력 ───
searchParams?: Record<string, any>; // 검색 조건 (적용 시 필터링/API 재호출)
}
```
**렌더링 규칙:**
1. `fields`에서 `visible: true`인 것만 컬럼으로 표시
2. `order` 순으로 정렬
3. `width` 적용 (없으면 타입별 기본값)
4. `align` 적용
5. `sortable: true`인 컬럼은 헤더 클릭 시 정렬
6. 셀 값은 `CellRenderer`가 FieldType에 따라 포맷팅
7. 행 클릭 시 `onRowSelect` 호출
8. 체크박스 선택 시 `onRowsSelect` 호출
**데이터 조회:**
- `config.autoLoad: true`이면 마운트 시 자동으로 `GET /api/data/{tableName}` 호출
- `searchParams`가 변경되면 재조회
- 페이지네이션은 FcPagination과 연동
### 4.4 FcForm — 입력 폼
```typescript
// frontend/components/fc/FcForm.tsx
interface FcFormProps {
fields: FieldConfig[]; // 필드 정의
config?: FormConfig; // 폼 설정
initialData?: Record<string, any>; // 수정 모드 시 초기값
// ─── DataPort 출력 ───
onSubmit?: (data: Record<string, any>) => void; // formData(row)
onSaved?: (data: Record<string, any>) => void; // savedRow(row)
// ─── DataPort 입력 ───
loadRow?: Record<string, any>; // 외부에서 행 데이터 로드
}
```
**렌더링 규칙:**
1. `fields`에서 `system: true`인 것은 숨김
2. `visible: true`인 것만 표시
3. `order` 순으로 배치
4. `config.columns` (1/2/3)에 따라 그리드 레이아웃
5. 각 필드는 `FieldRenderer(mode='form')`로 렌더
6. `required: true`인 필드에 * 표시
7. `editable: false`인 필드는 disabled
8. `pk: true`이고 `type: 'code'`이면 자동채번 표시 (readonly)
9. `config.sections`가 있으면 섹션별로 그룹핑
**유효성 검증:**
- 제출 시 `required: true` + 값이 비어있으면 에러 표시
- empty 판단: `value === null || value === undefined || value === ''` (0, false는 유효)
### 4.5 FcSearch — 검색 필터
```typescript
// frontend/components/fc/FcSearch.tsx
interface FcSearchProps {
fields: FieldConfig[]; // 전체 필드 (searchable: true인 것만 렌더)
config?: SearchConfig;
// ─── DataPort 출력 ───
onSearch?: (params: Record<string, any>) => void; // searchParams(params)
}
```
**렌더링 규칙:**
1. `fields`에서 `searchable: true`인 것만 표시
2. 각 필드는 `FieldRenderer(mode='search')`로 렌더
3. `config.layout: 'inline'`이면 한 줄에 나열, `'stacked'`이면 세로
4. "검색" 버튼 + "초기화" 버튼 (showResetButton)
5. `config.autoSearch: true`이면 입력 시 300ms 디바운스 후 자동 검색
6. 검색 버튼 클릭 또는 자동 검색 시 `onSearch(params)` 호출
**검색 파라미터 형식:**
```typescript
// date 범위: { order_date_from: '2026-01-01', order_date_to: '2026-12-31' }
// select 다중: { status: ['확정', '완료'] }
// text 부분 일치: { customer_name: '삼성' }
// number 범위: { amount_min: 1000, amount_max: 9999 }
```
### 4.6 FcButton / FcButtonBar
```typescript
// frontend/components/fc/FcButton.tsx
interface FcButtonProps {
config: ButtonConfig;
onClick?: () => void; // clicked(value)
disabled?: boolean;
}
// frontend/components/fc/FcButtonBar.tsx
interface FcButtonBarProps {
config: ButtonBarConfig;
onAction?: (actionType: ActionType) => void;
}
```
**ActionType 12종 (invyone-component.ts에 정의됨):**
save, edit, delete, add, cancel, close, navigate, popup, search, reset, submit, approval
### 4.7 FcPagination
```typescript
// frontend/components/fc/FcPagination.tsx
interface FcPaginationProps {
config?: PaginationConfig;
total: number;
page: number;
onPageChange?: (params: { page: number; size: number }) => void; // pageChange(params)
}
```
### 4.8 CellRenderer — 테이블 셀 포맷터
```typescript
// frontend/components/fc/table/CellRenderer.tsx
interface CellRendererProps {
field: FieldConfig;
value: any;
}
/**
* FieldType에 따라 셀 내용을 포맷팅한다:
* - number: format 적용 (#,##0 → 1,234,567)
* - date: format 적용 (YYYY-MM-DD)
* - checkbox: ✓/✗ 아이콘
* - entity: ref.displayColumn 값 표시 (별도 조회 필요 시 캐시)
* - textarea: 40자 말줄임 (...)
* - file: 파일명 링크
* - 나머지: 텍스트 그대로
*/
```
---
## 5. 데이터 연동 (CRUD API)
FcTable이 데이터를 조회하고, FcForm이 데이터를 저장할 때 사용하는 API.
기존 `DataController.java`가 범용 CRUD를 제공하지만, FieldConfig 기반으로 파라미터를 맞춰주는 래퍼가 필요.
**프론트엔드 API: `frontend/lib/api/fcData.ts`**
```typescript
/**
* ★ 별도 인터페이스 정의 안 함 — 백엔드가 Map<String, Object>이므로 프론트도 Record<string, any>
* ★ 유일하게 타입이 있는 건 FieldConfig (invyone-component.ts에 규격 확정된 것)
*/
/** FieldConfig 기반 목록 조회 */
export async function fcList(params: Record<string, any>): Promise<Record<string, any>>;
/** FieldConfig 기반 단건 조회 */
export async function fcGet(tableName: string, id: string): Promise<Record<string, any>>;
/** FieldConfig 기반 등록 */
export async function fcInsert(tableName: string, data: Record<string, any>): Promise<any>;
/** FieldConfig 기반 수정 */
export async function fcUpdate(tableName: string, id: string, data: Record<string, any>): Promise<any>;
/** FieldConfig 기반 삭제 (soft delete) */
export async function fcDelete(tableName: string, ids: string[]): Promise<any>;
```
**백엔드**: 기존 `DataController` 또는 `DataAdvancedController`의 엔드포인트를 활용. 필요하면 FieldConfig의 searchable/computed 등을 반영하는 동적 쿼리 빌더 추가.
---
## 6. 테스트 페이지
구현 확인용 테스트 페이지를 하나 만든다:
**`frontend/app/(main)/test-fc/page.tsx`**
```typescript
/**
* FieldConfig 컴포넌트 테스트 페이지
*
* 1. 드롭다운에서 테이블 선택
* 2. getTableFields(tableName) 호출 → FieldConfig[] 수신
* 3. FcSearch + FcTable + FcForm을 동시에 렌더
* 4. FcSearch → searchParams → FcTable (검색 연동)
* 5. FcTable 행 클릭 → selectedRow → FcForm (데이터 로드)
* 6. FcForm 저장 → DB에 실제 저장 → FcTable 새로고침
*/
```
이 페이지가 동작하면 Phase 2 완료.
---
## 7. 스타일링 규칙
1. v5 CSS 토큰 사용 (`var(--v5-primary)`, `var(--glass)`, `var(--glow-sm)` 등)
2. 컴팩트 폰트 (0.55~0.85rem)
3. 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)` + `var(--glass-border)`
4. 다크/라이트 모드 둘 다 지원
5. CSS 파일: `frontend/styles/fc-components.css` (필요 시) 또는 Tailwind 클래스 활용
6. 기존 `v5-layout.css``globals.css`의 토큰 먼저 확인 후 재사용
---
## 8. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-10-phase1-implementation-log.md` | **Phase 1 구현 결과** — API 응답 형태, 병합 규칙, 실 DB 테스트 결과, 범위 밖 항목 |
| `frontend/types/invyone-component.ts` | **모든 타입 정의 (진실의 원천)** |
| `notes/gbpark/2026-04-08-invyone-component-spec.md` (Section 2.2) | **렌더링 계약 테이블** — 반드시 이대로 구현 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 4) | 렌더링 계약 요약 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | mockup의 프리뷰 렌더 함수 (pvTable, pvForm, pvSearch) — UI 감각 참고 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/05-widgets.css` | HR 테이블 스타일 — 테이블 CSS 참고 |
| `frontend/styles/v5-layout.css` | v5 디자인 토큰 |
| `frontend/app/globals.css` | Tailwind/shadcn 토큰 |
---
## 9. 완료 기준
1. **FcTable**: FieldConfig[]과 데이터를 받아 테이블을 렌더링한다. 정렬, 행 선택, 페이지네이션이 동작한다.
2. **FcForm**: FieldConfig[]을 받아 입력 폼을 렌더링한다. 10종 FieldType 전부 동작한다. required 검증이 동작한다.
3. **FcSearch**: FieldConfig[]에서 searchable 필드만 추출하여 검색 UI를 렌더한다. 검색 시 params 객체를 반환한다.
4. **FcSearch → FcTable 연동**: 검색 실행 시 테이블이 필터링된 데이터를 표시한다.
5. **FcTable → FcForm 연동**: 행 클릭 시 폼에 해당 행 데이터가 로드된다.
6. **FcForm 저장**: 폼 제출 시 DB에 INSERT/UPDATE가 실행되고 테이블이 새로고침된다.
7. **테스트 페이지** (`/test-fc`)에서 위 전체 흐름이 실제 DB 데이터로 동작한다.
---
## 10. 다음 단계 연결
Phase 3 (개발자 빌더)는 이 컴포넌트들을:
- 팔레트에서 선택하여 캔버스에 배치
- 속성 패널에서 config(TableConfig, FormConfig 등) 조정
- Template JSON으로 저장/로드
Phase 3은 FcTable/FcForm/FcSearch를 "배치하고 설정하는 UI"이고, Phase 2는 그 컴포넌트 자체다.
@@ -0,0 +1,305 @@
# Phase 2 구현 작업기록 — FieldConfig 기반 컴포넌트 (FcTable/FcForm/FcSearch)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase2-fieldconfig-components.md`
> **상태**: 구현 완료 + TypeScript 타입체크 통과 (tsc --noEmit 에러 0개)
---
## 1. 생성된 파일 (17개)
### 1.1 파일 구조
```
frontend/
├── components/fc/ ← FieldConfig 기반 컴포넌트 (신규)
│ ├── index.ts ← 공개 exports
│ ├── FcTable.tsx ← 데이터 테이블 (TanStack Table v8)
│ ├── FcForm.tsx ← 입력 폼 (자체 상태 관리)
│ ├── FcSearch.tsx ← 검색 필터
│ ├── FcButton.tsx ← 단일 버튼 (confirm 팝업 포함)
│ ├── FcButtonBar.tsx ← 버튼 그룹
│ ├── FcPagination.tsx ← 페이지네이션
│ ├── fields/ ← FieldType별 렌더러 (10종)
│ │ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
│ │ ├── TextField.tsx
│ │ ├── NumberField.tsx
│ │ ├── DateField.tsx
│ │ ├── DateTimeField.tsx
│ │ ├── SelectField.tsx
│ │ ├── EntityField.tsx
│ │ ├── CheckboxField.tsx
│ │ ├── TextareaField.tsx
│ │ ├── FileField.tsx
│ │ └── CodeField.tsx
│ └── table/ ← 테이블 전용
│ └── CellRenderer.tsx ← 셀 포맷 디스패처
├── lib/api/
│ └── fcData.ts ← FieldConfig 기반 CRUD API 래퍼 (신규)
└── app/(main)/test-fc/
└── page.tsx ← 테스트 페이지 (신규)
```
---
## 2. 핵심 컴포넌트 상세
### 2.1 FieldRenderer — FieldType→위젯 디스패처
`frontend/components/fc/fields/FieldRenderer.tsx`
- `mode='form'` / `mode='search'` 분기로 같은 FieldConfig가 다른 위젯으로 렌더
- switch/case로 10종 FieldType을 개별 컴포넌트에 위임
- 모든 필드 공통 인터페이스: `{ field, value, onChange, mode, disabled?, error? }`
### 2.2 렌더링 계약 구현 (spec Section 2.2 전부 준수)
| FieldType | FcTable 셀 | FcForm 입력 | FcSearch 검색 |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<Input type="text">` | `<Input>` (부분 일치) |
| `number` | `#,##0` 포맷 (toLocaleString) | `<Input type="number">` | min~max 범위 2개 |
| `date` | YYYY-MM-DD 포맷 | `<Input type="date">` | from~to 범위 2개 |
| `datetime` | YYYY-MM-DD HH:mm 포맷 | `<Input type="datetime-local">` | from~to 범위 2개 |
| `select` | 텍스트 그대로 | Radix `<Select>` 단일 | MultiSelect (Checkbox 드롭다운) |
| `entity` | ref.displayColumn 그대로 | `<Input>` + 검색 버튼 | `<Input>` + 검색 버튼 |
| `checkbox` | ✓(green) / ✗(muted) 아이콘 | Radix `<Checkbox>` | `<Select>` 전체/✓/✗ |
| `textarea` | 40자 말줄임 (...) | `<textarea rows={3}>` | `<Input>` (부분 일치) |
| `file` | 파일명 + FileText 아이콘 | 파일 업로드 버튼 | — (렌더 안 함) |
| `code` | 텍스트 그대로 | readonly + Lock 아이콘 | `<Input>` (완전 일치) |
### 2.3 FcTable — 데이터 테이블
`frontend/components/fc/FcTable.tsx` (~190줄)
**기술 스택**: TanStack Table v8 (`@tanstack/react-table`)
**기능:**
- `fields`에서 `visible: true`인 것만 컬럼으로 표시, `order` 순 정렬
- `width`, `align` 적용 (없으면 타입별 기본값)
- `sortable: true`인 컬럼은 헤더 클릭 시 정렬 토글 (asc→desc→none)
- 행 클릭 시 `onRowSelect(row)` 호출 + 선택 행 하이라이트
- `showCheckbox: true` + `selectionMode: 'multiple'`일 때 체크박스 전체선택/개별선택
- 로딩 스피너, 빈 데이터 표시
- `CellRenderer`가 FieldType에 따라 셀 포맷팅
**Props:**
```typescript
interface FcTableProps {
fields: FieldConfig[];
data: Record<string, any>[];
config?: Partial<TableConfig>;
loading?: boolean;
onRowSelect?: (row: Record<string, any>) => void;
onRowsSelect?: (rows: Record<string, any>[]) => void;
selectedRowIndex?: number;
}
```
### 2.4 FcForm — 입력 폼
`frontend/components/fc/FcForm.tsx` (~130줄)
**상태 관리**: `useState` 직접 사용 (React Hook Form 의존성 제거 — 폼이 단순하고 FieldConfig 동적 필드라 자체 관리가 적합)
**기능:**
- `system: true` 필드 숨김, `visible: true`만 표시, `order`
- `config.columns` (1/2/3)에 따라 CSS Grid 레이아웃
- `config.sections`가 있으면 섹션별로 그룹핑 (라벨 + 구분선)
- `required: true` 필드에 * 표시
- `editable: false` 필드 disabled
- `pk: true` + `type: 'code'`이면 readonly (자동채번)
- `loadRow` 변경 시 폼 데이터 자동 갱신
- 제출 시 `required` 검증 (null/undefined/'' 만 empty, 0/false는 유효)
- 초기화 버튼 (loadRow/initialData로 복원)
**Props:**
```typescript
interface FcFormProps {
fields: FieldConfig[];
config?: Partial<FormConfig>;
initialData?: Record<string, any>;
onSubmit?: (data: Record<string, any>) => void;
loadRow?: Record<string, any>;
}
```
### 2.5 FcSearch — 검색 필터
`frontend/components/fc/FcSearch.tsx` (~110줄)
**기능:**
- `searchable: true` + `!system` 필드만 추출, `order`
- `FieldRenderer(mode='search')`로 각 필드 렌더
- `config.layout: 'inline'` (한 줄 나열) / `'stacked'` (세로)
- `config.autoSearch: true`이면 300ms 디바운스 후 자동 `onSearch`
- 검색/초기화 버튼
- `buildSearchParams()` 변환 로직:
```
date/datetime 범위 → { column_from: '2026-01-01', column_to: '2026-12-31' }
number 범위 → { column_min: 1000, column_max: 9999 }
select 다중 → { column: ['확정', '완료'] }
text 부분 일치 → { column: '삼성' }
```
### 2.6 CellRenderer — 테이블 셀 포맷터
`frontend/components/fc/table/CellRenderer.tsx` (~85줄)
| 타입 | 포맷 |
|---|---|
| number | `#,##0``toLocaleString('ko-KR')`, 소수점 포맷도 지원 |
| date | `YYYY-MM-DD` (Date 파싱 후 수동 포맷) |
| datetime | `YYYY-MM-DD HH:mm` |
| checkbox | ✓ (green Check 아이콘) / ✗ (muted X 아이콘) |
| textarea | 40자 초과 시 `...` 말줄임 + title 툴팁 |
| file | FileText 아이콘 + 파일명 링크 스타일 |
| null/undefined | `-` (muted) |
| 기타 | `String(value)` |
---
## 3. 보조 컴포넌트
### 3.1 FcButton
- `ButtonConfig.variant` 5종 (primary/default/destructive/outline/ghost) → v5 토큰 매핑
- `confirm` 속성 있으면 클릭 시 확인/취소 인라인 팝업
### 3.2 FcButtonBar
- `ButtonBarConfig.buttons[]` 순회하며 `FcButton` 렌더
- `onAction(actionType)` 콜백으로 12종 ActionType 전달
### 3.3 FcPagination
- 총 건수, 페이지 크기 선택기 (10/20/50/100)
- 5페이지 범위 번호 표시 + 처음/이전/다음/끝 버튼
- `onPageChange({ page, size })` 콜백
---
## 4. API 래퍼
### 4.1 fcData.ts
`frontend/lib/api/fcData.ts` (~35줄)
기존 `dataApi` (frontend/lib/api/data.ts)를 감싸는 얇은 래퍼.
| 함수 | 용도 | 내부 호출 |
|---|---|---|
| `fcList(params)` | 목록 조회 (검색+페이징) | `dataApi.getTableData()` |
| `fcGet(tableName, id)` | 단건 조회 | `dataApi.getRecordDetail()` |
| `fcInsert(tableName, data)` | 등록 | `dataApi.createRecord()` |
| `fcUpdate(tableName, id, data)` | 수정 | `dataApi.updateRecord()` |
| `fcDelete(tableName, ids)` | 삭제 (복수) | `dataApi.deleteRecord()` × N |
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>`
---
## 5. 테스트 페이지
### 5.1 `/test-fc` (frontend/app/(main)/test-fc/page.tsx)
**레이아웃**: 좌(검색+테이블+페이지네이션) / 우(폼) 2컬럼
**흐름:**
```
1. 드롭다운에서 테이블 선택
2. getMetaFields(tableName) → FieldConfig[] 수신
3. FcSearch + FcTable + FcForm 동시 렌더
4. FcSearch 검색 → searchParams → fcList() 재조회 → FcTable 갱신
5. FcTable 행 클릭 → selectedRow → FcForm 데이터 로드
6. FcForm 저장 → fcInsert/fcUpdate → 성공 메시지 + FcTable 새로고침
```
**기능:**
- 테이블 목록에 `has_custom_meta` 표시 (★)
- 테이블 선택 시 필드 수, PK 표시
- 수정/신규 모드 자동 전환 (selectedRow 유무)
- 성공/실패 알림 (3초 자동 소멸)
---
## 6. v5 디자인 토큰 사용 목록
모든 컴포넌트에서 즉흥 hex/rgb 사용 안 함. v5-layout.css 토큰만 사용:
| 용도 | 토큰 |
|---|---|
| 배경 | `var(--v5-glass)`, `var(--v5-surface)`, `var(--v5-bg-subtle)` |
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border)`, `var(--v5-border-subtle)` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 강조 | `var(--v5-primary)`, `var(--v5-primary-glow)` |
| 상태 | `var(--v5-green)` (✓), `var(--v5-red)` (* 필수, 에러) |
| 호버 | `var(--v5-surface-hover)` |
| 글로우 | `var(--v5-glow-sm)` |
| 블러 | `backdrop-blur-[20px]` (v5 글래스 패턴) |
폰트 크기: 0.65rem(라벨) ~ 0.75rem(본문) — v5 컴팩트 스케일 준수
---
## 7. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 프론트 타입: `Record<string, any>` (FieldConfig만 예외) | ✅ |
| 별도 인터페이스 정의 금지 | ✅ — Props만 인라인 정의 |
| FieldConfig/TableConfig 등은 invyone-component.ts 타입 사용 | ✅ |
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
| shadcn UI 컴포넌트 재활용 (Input, Select, Checkbox) | ✅ |
| 기존 dataApi 활용 (중복 API 안 만듦) | ✅ |
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
---
## 8. TypeScript 검증 결과
```bash
npx tsc --noEmit 2>&1 | grep -E "(components/fc/|test-fc/|lib/api/fcData)"
# 출력 없음 — 에러 0개
```
기존 코드(admin 페이지)의 타입 에러는 Phase 2와 무관.
---
## 9. Phase 2 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| entity 팝업 검색 (ref 테이블 조회) | Phase 3+ | 현재는 텍스트 입력 + 검색 버튼 UI만 |
| category 공통코드 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
| 실제 파일 업로드 구현 | Phase 3+ | 현재는 파일 선택 UI만 |
| computed 수식 파서 | Phase 5 | AST 기반 안전한 파서 필요 |
| DataPort 이벤트 버스 | Phase 3 | 빌더에서 컴포넌트 간 연결 시 |
| inlineEdit (테이블 인라인 편집) | Phase 3+ | TableConfig.inlineEdit는 정의만 |
| 엑셀 내보내기 | Phase 3+ | toolbar.showExcel UI 미구현 |
| select options의 value/label 분리 | Phase 2+ | 현재 label만 string[]으로 반환 |
| defaultValue, placeholder 자동 세팅 | Phase 2+ | Phase 1에서 미구현 |
---
## 10. 사용법 (다음 Phase에서 참고)
```tsx
import { FcTable, FcForm, FcSearch, FcPagination } from '@/components/fc';
import { getMetaFields } from '@/lib/api/meta';
import { fcList } from '@/lib/api/fcData';
// 1. FieldConfig 가져오기
const meta = await getMetaFields('sales_order_mng');
const fields = meta.fields;
// 2. 데이터 조회
const result = await fcList({ tableName: 'sales_order_mng', page: 1, size: 20 });
// 3. 컴포넌트 렌더
<FcSearch fields={fields} onSearch={handleSearch} />
<FcTable fields={fields} data={result.data} onRowSelect={handleSelect} />
<FcForm fields={fields} loadRow={selectedRow} onSubmit={handleSave} />
<FcPagination total={result.total} page={1} onPageChange={handlePage} />
```
@@ -0,0 +1,533 @@
# Phase 3: 개발자 빌더 — 수동 템플릿 구성
> **목적**: 개발자가 테이블을 선택하고, FieldConfig 기반 컴포넌트를 배치하여 Template JSON을 만드는 빌더 UI 구현
> **전제 조건**: Phase 1 (DB 메타 API) + Phase 2 (FcTable/FcForm/FcSearch 컴포넌트) 완료
> **산출물**: 개발자 빌더 페이지 + Template CRUD API + Template JSON 저장/로드
> **다음 단계**: Phase 4에서 사용자가 이 Template을 대시보드(=메뉴)에 카드로 배치
---
## 1. 핵심 흐름
```
개발자 모드 진입 (톱니바퀴 버튼)
→ 테이블 선택 (드롭다운)
→ FieldConfig[] 자동 로드 (Phase 1 API)
→ 팔레트에서 컴포넌트 선택 → 캔버스에 배치
→ 속성 패널에서 설정 조정 (필드 ON/OFF, 컬럼 순서, config 등)
→ 3뷰 탭 전환 (목록/등록/수정)
→ 저장 → Template JSON이 DB에 저장됨
```
**자동생성(⚡ 버튼)과 프리셋은 Phase 6에서 구현. 이 단계에서는 수동 구성만.**
---
## 2. 현재 존재하는 것
| 파일 | 상태 | 활용 |
|---|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **mockup 완성** | UI/UX 참조 (진실의 원천) |
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` (admin-builder 섹션) | **mockup 완성** | HTML 구조 참조 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **mockup 완성** | 개발자 모드 스타일 참조 |
| `frontend/types/invyone-component.ts` | **타입 완성** | Template, Component, ViewConfig 등 |
| `frontend/components/screen/ScreenDesigner.tsx` | 7,986줄 (VEX식) | 패턴 참고용, 직접 사용 안 함 |
| `backend-spring/.../TemplateStandardController.java` | 기존 VEX 템플릿 API | 확장 또는 신규 생성 |
---
## 3. 빌더 UI 레이아웃
mockup의 `developer.html` + `08-admin-builder.js` 기반. 3패널 IDE 스타일.
```
┌─────────────────────────────────────────────────────────┐
│ 헤더: [INVYONE] [DEV] [테이블 선택 ▼] [뷰 탭: 목록|등록|수정] [저장] [미리보기] │
├──────────┬────────────────────────────┬──────────────────┤
│ 팔레트 │ 캔버스 │ 속성 패널 │
│ (180px) │ (유동) │ (260px) │
│ │ │ │
│ ── 데이터 │ ┌──────────────────────┐ │ 컴포넌트 정보 │
│ 📊 테이블 │ │ [검색 필터] │ │ ───────────── │
│ 🔍 검색 │ │ │ │ 종류: 테이블 │
│ 📝 폼 │ │ [데이터 테이블] │ │ 이름: 수주 목록 │
│ │ │ │ │ │
│ ── 액션 │ │ │ │ 위치·크기 │
│ 🔘 버튼 │ └──────────────────────┘ │ X: 16 Y: 58 │
│ │ ┌──────────────────────┐ │ W: 854 H: 380 │
│ ── 표시 │ │ [페이지네이션] │ │ │
│ 📌 제목 │ └──────────────────────┘ │ 표시할 컬럼 │
│ ── 구분 │ │ ☑ 수주번호 │
│ │ ☑ 수주일 │
│ 빈 슬롯 │ │ ☑ 거래처 │
│ │ │ ☐ 비고 │
├──────────┴────────────────────────────┴──────────────────┤
│ 상태바: 블록 5개 · order_management_test · 수정됨 │
└─────────────────────────────────────────────────────────┘
```
### 3.1 팔레트 (좌측 180px)
**8종 컴포넌트** (아키텍처 문서 Section 3.2 확정):
| 아이콘 | 이름 | ComponentType | 드래그→캔버스 |
|---|---|---|---|
| 📊 | 데이터 테이블 | `table` | FcTable 배치 |
| 🔍 | 검색 필터 | `search` | FcSearch 배치 |
| 📝 | 입력 폼 | `form` | FcForm 배치 |
| 🔘 | 버튼 | `button` | FcButton 배치 |
| 📈 | 통계 카드 | `stats` | 통계 카드 배치 |
| 📌 | 제목/텍스트 | `title` | 텍스트 배치 |
| ── | 구분선 | `divider` | 구분선 배치 |
| 📊 | 차트 | `chart` | (Phase 2+에서 확장) |
드래그앤드롭으로 캔버스에 추가. 팔레트 아이템은 mockup의 `dev-pal-item` 스타일 참고.
### 3.2 캔버스 (중앙)
- 절대 좌표 배치 (position: absolute)
- 블록(컴포넌트)은 점선 보더 + 라벨
- 블록 클릭 → 선택 (파란 보더)
- 블록 드래그 → 이동 (Shift = 8px 스냅)
- 블록 우하단 핸들 → 리사이즈
- 블록 안에 Phase 2의 FcTable/FcForm/FcSearch가 프리뷰로 렌더됨
### 3.3 속성 패널 (우측 260px)
블록 선택 시 해당 컴포넌트의 설정을 편집:
**공통 속성:**
- 컴포넌트 종류 (readonly)
- 이름 (label)
- 위치: X, Y, W, H (숫자 입력, 캔버스와 양방향 동기화)
**타입별 속성 (invyone-component.ts의 Config 타입):**
| ComponentType | 설정 항목 |
|---|---|
| `table` | 표시할 컬럼 체크리스트, 페이지 크기, 선택 모드, 인라인 편집, 자동 로드, 정렬 |
| `form` | 표시할 필드 체크리스트, 컬럼 수(1/2/3), 섹션 구분, 저장 방식(INSERT/UPDATE/UPSERT) |
| `search` | 검색 대상 필드 체크리스트, 날짜 범위, 초기화 버튼, 자동 검색 |
| `button` | 버튼 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지 |
| `title` | 텍스트, 글꼴 크기, 글꼴 두께, 정렬 |
| `stats` | 통계 항목 목록 (라벨, 컬럼, 집계 방식) |
| `divider` | 선 스타일 (solid/dashed/dotted) |
**필드 체크리스트** (table/form/search 공통):
- 현재 선택된 테이블의 FieldConfig[] 표시
- 체크박스로 visible ON/OFF
- 드래그로 순서 변경
- 클릭하면 필드 상세 설정 열림 (label, width, required 등)
### 3.4 3뷰 탭
헤더에 `[목록 | 등록 | 수정]` 탭:
- **목록 (list)**: 검색 + 테이블 + 버튼바 + 페이지네이션
- **등록 (create)**: 팝업 오버레이 안에 폼 + 버튼
- **수정 (edit)**: 등록과 비슷, `extends: 'create'` 가능
목록은 캔버스 직접 배치, 등록/수정은 팝업 프레임 안에서 배치.
mockup 참조: `08-admin-builder.js``abSwitchView()`, `.ab-popup-overlay` 구조.
---
## 4. Template JSON 구조
`frontend/types/invyone-component.ts``Template` 인터페이스 그대로:
```typescript
interface Template {
templateId: string;
name: string;
category: string;
description?: string;
primaryTable: string;
fields: FieldConfig[]; // 모든 뷰가 공유하는 유일한 필드 정의
views: {
list: ViewConfig; // { components: Component[] }
create: ViewConfig;
edit: ViewConfig; // extends: 'create' 가능
};
connections: Connection[]; // DataPort 연결 (Phase 2의 DataPort)
companyCode: string;
version: number;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
```
---
## 5. 백엔드 API 설계
### 5.1 Template CRUD
**`GET /api/templates`** — 템플릿 목록
```
Response: { data: [{ templateId, name, category, primaryTable, status, updatedAt }] }
```
**`GET /api/templates/{templateId}`** — 템플릿 상세 (Template JSON 전체)
```
Response: { data: Template }
```
**`POST /api/templates`** — 템플릿 생성
```
Request: Template (templateId 제외, 서버에서 생성)
Response: { data: { templateId: "tpl_xxx" } }
```
**`PUT /api/templates/{templateId}`** — 템플릿 수정
```
Request: Template (전체)
Response: { data: Template }
```
**`DELETE /api/templates/{templateId}`** — 템플릿 삭제
**`PUT /api/templates/{templateId}/publish`** — 템플릿 게시 (draft → published)
### 5.2 DB 테이블
**`templates` 테이블 (신규 생성):**
```sql
CREATE TABLE templates (
template_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
category VARCHAR(50),
description TEXT,
primary_table VARCHAR(100) NOT NULL,
fields JSONB NOT NULL, -- FieldConfig[] JSON
views JSONB NOT NULL, -- { list, create, edit } JSON
connections JSONB DEFAULT '[]', -- Connection[] JSON
company_code VARCHAR(20) NOT NULL DEFAULT '*',
version INTEGER DEFAULT 1,
status VARCHAR(20) DEFAULT 'draft', -- draft | published
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_templates_company ON templates(company_code);
CREATE INDEX idx_templates_table ON templates(primary_table);
CREATE INDEX idx_templates_status ON templates(status);
```
### 5.3 Java 구현
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
**신규 파일:**
| 파일 | 역할 |
|---|---|
| `TemplateController.java` | `/api/templates/*` 엔드포인트 |
| `TemplateService.java` | Template CRUD + 버전 관리 |
| `TemplateMapper.xml` | MyBatis SQL (JSONB 읽기/쓰기) |
**주의:** 기존 `TemplateStandardController`는 VEX용이므로 별도로 신규 생성. 이름 충돌 방지.
**TemplateService.java 구조:**
```java
@Service
@Slf4j
public class TemplateService extends BaseService {
@Autowired
private CommonService commonService;
public Map<String, Object> getTemplateList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne("template.getTemplateListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList("template.getTemplateList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getTemplateInfo(Map<String, Object> params) {
return sqlSession.selectOne("template.getTemplateInfo", params);
}
@Transactional
public Map<String, Object> insertTemplate(Map<String, Object> params) {
// template_id 자동 생성 (UUID)
// fields, views, connections → ObjectMapper로 JSON 문자열 변환 후 #{fields}::jsonb
sqlSession.insert("template.insertTemplate", params);
return params;
}
@Transactional
public void updateTemplate(Map<String, Object> params) {
sqlSession.update("template.updateTemplate", params);
}
@Transactional
public int deleteTemplate(Map<String, Object> params) {
return sqlSession.update("template.deleteTemplate", params);
}
}
```
**★ 파일명: `template.xml`, namespace: `template` (덕일 스타일)**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="template">
<select id="getTemplateList" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, PRIMARY_TABLE
, STATUS
, UPDATED_DATE
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
<select id="getTemplateListCnt" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM TEMPLATES
WHERE 1=1
AND IS_ACTIVE != 'D'
<include refid="common.companyCodeFilter"/>
</select>
<select id="getTemplateInfo" parameterType="map" resultType="map">
SELECT
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
, UPDATED_BY
, UPDATED_DATE
FROM TEMPLATES
WHERE TEMPLATE_ID = #{template_id}
AND IS_ACTIVE != 'D'
</select>
<insert id="insertTemplate" parameterType="map">
INSERT INTO TEMPLATES (
TEMPLATE_ID
, NAME
, CATEGORY
, DESCRIPTION
, PRIMARY_TABLE
, FIELDS
, VIEWS
, CONNECTIONS
, COMPANY_CODE
, VERSION
, STATUS
, CREATED_BY
, CREATED_DATE
) VALUES (
#{template_id}
, #{name}
, #{category}
, #{description}
, #{primary_table}
, #{fields}::jsonb
, #{views}::jsonb
, #{connections}::jsonb
, #{company_code}
, 1
, 'draft'
, #{user_id}
, NOW()
)
</insert>
<update id="updateTemplate" parameterType="map">
UPDATE TEMPLATES
SET
NAME = #{name}
, CATEGORY = #{category}
, DESCRIPTION = #{description}
, FIELDS = #{fields}::jsonb
, VIEWS = #{views}::jsonb
, CONNECTIONS = #{connections}::jsonb
, VERSION = VERSION + 1
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
</update>
<update id="deleteTemplate" parameterType="map">
UPDATE TEMPLATES
SET IS_ACTIVE = 'D'
, UPDATED_BY = #{user_id}
, UPDATED_DATE = NOW()
WHERE TEMPLATE_ID = #{template_id}
</update>
</mapper>
```
---
## 6. 프론트엔드 구현
### 6.1 파일 구조
```
frontend/app/(main)/admin/builder/
└── page.tsx ← 빌더 메인 페이지
frontend/components/builder/
├── BuilderLayout.tsx ← 3패널 레이아웃
├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
├── BuilderCanvas.tsx ← 중앙 캔버스 (블록 배치/드래그/리사이즈)
├── BuilderProps.tsx ← 우측 속성 패널
├── BuilderToolbar.tsx ← 상단 툴바 (테이블 선택, 뷰 탭, 저장/미리보기)
├── BuilderBlock.tsx ← 캔버스 위의 개별 블록
├── BuilderViewTabs.tsx ← 3뷰 탭 (목록/등록/수정)
├── BuilderPopupFrame.tsx ← 등록/수정 팝업 편집 프레임
├── props/ ← 타입별 속성 패널
│ ├── TableProps.tsx
│ ├── FormProps.tsx
│ ├── SearchProps.tsx
│ ├── ButtonProps.tsx
│ ├── TitleProps.tsx
│ ├── StatsProps.tsx
│ └── FieldListEditor.tsx ← 필드 체크리스트 (공통)
└── hooks/
├── useBuilderState.ts ← 빌더 상태 관리 (Zustand)
└── useBlockDrag.ts ← 블록 드래그/리사이즈 로직
frontend/lib/api/template.ts ← Template CRUD API 클라이언트
```
### 6.2 빌더 상태 (Zustand)
```typescript
interface BuilderState {
// 테이블/필드
tableName: string | null;
fields: FieldConfig[];
// 현재 뷰
currentView: 'list' | 'create' | 'edit';
// 블록 목록 (뷰별)
blocks: {
list: Component[];
create: Component[];
edit: Component[];
};
// 선택된 블록
selectedBlockId: string | null;
// 템플릿 메타
templateId: string | null;
templateName: string;
category: string;
// 변경 상태
isDirty: boolean;
// 액션
setTable: (tableName: string, fields: FieldConfig[]) => void;
switchView: (view: 'list' | 'create' | 'edit') => void;
addBlock: (type: ComponentType, position: Position) => void;
removeBlock: (id: string) => void;
updateBlock: (id: string, updates: Partial<Component>) => void;
selectBlock: (id: string | null) => void;
moveBlock: (id: string, x: number, y: number) => void;
resizeBlock: (id: string, w: number, h: number) => void;
toTemplate: () => Template; // 현재 상태 → Template JSON
fromTemplate: (tpl: Template) => void; // Template JSON → 상태 로드
}
```
---
## 7. 스타일
개발자 모드는 **코스믹 글래스모피즘이 아닌 IDE 스타일** (mockup의 `09-developer.css` 참조):
- 중성 다크 그레이 (#121218, #1a1a22)
- 라이트 모드도 지원 (#f5f5f8, #ededf2)
- 글로우/블러 없음, 깔끔한 보더
- 액센트: 블루 (#5b9ef5)
- 폰트: 0.42~0.62rem (컴팩트)
---
## 8. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | **빌더 UX 진실의 원천** — 드래그, 리사이즈, 속성 패널, 3뷰 전부 구현됨 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | **개발자 모드 스타일** |
| `notes/gbpark/2026-04-08-invyone-mockup/developer.html` | 개발자 모드 HTML 구조 |
| `notes/gbpark/2026-04-08-invyone-mockup/builder-v2.html` | 빌더 v2 standalone |
| `frontend/types/invyone-component.ts` | Template, Component, Config 타입 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 팔레트 8종, DataPort, 3뷰 구조 |
---
## 9. Template fields 동기화 규칙 (★ 메타 드리프트 방지)
Template.fields는 저장 시점의 FieldConfig[] 스냅샷이다. DB 스키마(table_type_columns)가 나중에 바뀌면 드리프트가 발생한다.
**동기화 정책:**
1. **빌더에서 Template 열 때마다** Phase 1 API(`GET /api/meta/tables/{tableName}/fields`)를 호출하여 최신 메타와 비교
2. **비교 결과 표시**:
- 새 컬럼 추가됨 → 빌더 상단에 "N개 컬럼 추가됨" 알림 + 필드 목록에 `(신규)` 표시
- 기존 컬럼 삭제됨 → 해당 필드에 `(DB에서 삭제됨)` 경고 표시
- 타입/옵션 변경됨 → 해당 필드에 `(변경됨)` 표시
3. **동기화 버튼** ("메타 동기화") 클릭 시:
- 새 컬럼 → fields에 추가 (visible=false 기본)
- 삭제된 컬럼 → fields에서 제거 (연관 사용자 오버라이드도 자동 무시됨)
- 변경된 타입 → fields 업데이트
4. **자동 동기화는 안 함** — 개발자가 명시적으로 "동기화" 눌러야 적용 (의도치 않은 변경 방지)
5. **사용자 화면(Phase 4)에서는** Template.fields 스냅샷을 그대로 사용 (라이브 메타 조회 안 함 — 성능 + 안정성)
---
## 10. 완료 기준
1. `/admin/builder` 페이지에서 3패널 빌더가 동작한다
2. 드롭다운에서 테이블 선택 → FieldConfig[] 로드 → 팔레트 활성화
3. 팔레트에서 컴포넌트를 캔버스에 드래그앤드롭으로 추가할 수 있다
4. 캔버스에서 블록을 드래그(이동)/리사이즈할 수 있다
5. 블록 클릭 → 속성 패널에 해당 Config가 표시되고, 변경 시 캔버스에 반영된다
6. 필드 체크리스트에서 ON/OFF → 프리뷰에 실시간 반영
7. 3뷰 탭 (목록/등록/수정) 전환이 동작한다
8. 저장 → Template JSON이 DB에 저장된다
9. 기존 Template 로드 → 빌더에 상태 복원
10. 실제 테이블 (예: order_management_test)로 템플릿을 만들고 저장/로드 확인
---
## 10. 다음 단계 연결
Phase 4 (대시보드=메뉴)에서는:
- 사용자가 대시보드를 만들면 → 사이드바 메뉴에 등록됨
- 대시보드에 Template을 카드로 배치 → 카드 안에서 Phase 2의 FcTable/FcForm/FcSearch가 렌더됨
- 개발자가 만든 Template이 "사용 가능한 템플릿 라이브러리"에 나타남
@@ -0,0 +1,480 @@
# Phase 3 구현 작업기록 — 개발자 빌더 (수동 템플릿 구성)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase3-developer-builder.md`
> **상태**: 구현 완료 + TypeScript 타입체크 통과 + DB 테이블 생성 완료
---
## 1. 생성된 파일 (18개)
### 1.1 파일 구조
```
backend-spring/
├── src/main/java/com/erp/
│ ├── controller/TemplateController.java ← CRUD + publish 엔드포인트 (신규)
│ └── service/TemplateService.java ← Template CRUD 비즈니스 로직 (신규)
└── src/main/resources/mapper/
└── template.xml ← MyBatis SQL (신규)
frontend/
├── lib/api/
│ └── template.ts ← Template CRUD API 클라이언트 (신규)
├── styles/
│ └── developer.css ← IDE 스타일 개발자 테마 (신규)
├── components/builder/ ← 빌더 컴포넌트 디렉토리 (전체 신규)
│ ├── BuilderLayout.tsx ← 3패널 셸 + 키보드 단축키 + 상태바
│ ├── BuilderToolbar.tsx ← 헤더 + 도구모음 (테이블 선택, 뷰 탭, 저장)
│ ├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
│ ├── BuilderCanvas.tsx ← 중앙 캔버스 (드롭 영역, 팝업 뷰)
│ ├── BuilderBlock.tsx ← 개별 블록 (드래그/리사이즈 + 타입별 프리뷰)
│ ├── BuilderProps.tsx ← 우측 속성 패널 (공통 + 타입별 분기)
│ ├── hooks/
│ │ ├── useBuilderState.ts ← Zustand 빌더 상태관리
│ │ └── useBlockDrag.ts ← 블록 드래그/리사이즈 훅
│ └── props/
│ ├── FieldListEditor.tsx ← 필드 체크리스트 (공통)
│ ├── TableProps.tsx ← 테이블 속성 패널
│ ├── FormProps.tsx ← 폼 속성 패널
│ ├── SearchProps.tsx ← 검색 속성 패널
│ ├── ButtonProps.tsx ← 버튼/버튼바 속성 패널
│ └── TitleProps.tsx ← 제목 속성 패널
└── app/(main)/admin/builder/
└── page.tsx ← 빌더 페이지 진입점
```
### 1.2 수정된 파일 (2개)
| 파일 | 변경 내용 |
|---|---|
| `backend-spring/src/main/resources/application.yml` | DB 접속 정보 변경: `39.117.244.52:11132/testvex``211.115.91.141:11134/test_dev` |
| `CLAUDE.local.md` | DB 접속 정보 업데이트 기록 |
---
## 2. 백엔드 상세
### 2.1 TemplateController.java (~100줄)
`/api/templates` REST 컨트롤러. 6개 엔드포인트:
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/templates` | 템플릿 목록 (keyword, status, category 필터 + 페이지네이션) |
| `GET` | `/api/templates/{templateId}` | 템플릿 상세 (JSONB 파싱된 Template 전체) |
| `POST` | `/api/templates` | 템플릿 생성 (template_id 자동 생성: `tpl_` + UUID 12자) |
| `PUT` | `/api/templates/{templateId}` | 템플릿 수정 (version 자동 증가) |
| `PUT` | `/api/templates/{templateId}/publish` | 템플릿 게시 (draft → published) |
| `DELETE` | `/api/templates/{templateId}` | 템플릿 삭제 (소프트: IS_ACTIVE='D') |
**패턴**: MetaController와 동일 — `@RequestAttribute("company_code")`, `@RequestAttribute("user_id")`, `ApiResponse.success/error`
### 2.2 TemplateService.java (~120줄)
덕일 스타일 준수 — `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
| 메서드 | 역할 |
|---|---|
| `getTemplateList(params)` | CommonService.applyPagination → selectList + selectOne(Cnt) → buildListResponse |
| `getTemplateInfo(params)` | selectOne + JSONB 파싱 (fields, views, connections) |
| `insertTemplate(params)` | UUID 생성 + JSON 직렬화 + insert |
| `updateTemplate(params)` | JSON 직렬화 + update (VERSION + 1) |
| `publishTemplate(params)` | status='published' 업데이트 |
| `deleteTemplate(params)` | IS_ACTIVE='D' 소프트 삭제 |
**JSONB 처리 유틸 (private):**
- `parseJsonField(row, key)` — PostgreSQL JSONB 문자열 → Java Object (ObjectMapper)
- `stringifyJsonField(params, key)` — Java Object → JSON 문자열 (INSERT/UPDATE 전)
### 2.3 template.xml (~120줄)
MyBatis 매퍼. namespace=`template`, 쿼리 6개:
| 쿼리 ID | 용도 |
|---|---|
| `getTemplateList` | 목록 (keyword LIKE, status/category 필터, companyCodeFilter, dynamicOrderBy, pagination) |
| `getTemplateListCnt` | 목록 카운트 |
| `getTemplateInfo` | 단건 (FIELDS, VIEWS, CONNECTIONS JSONB 포함) |
| `insertTemplate` | 등록 (`#{fields}::jsonb` 캐스팅) |
| `updateTemplate` | 수정 (VERSION + 1) |
| `deleteTemplate` | 소프트 삭제 |
| `publishTemplate` | 게시 상태 변경 |
**common include 사용**: `companyCodeFilter`, `dynamicOrderBy`, `pagination`
**OGNL test**: 바깥 작은따옴표 규칙 준수 (`test='keyword != null and keyword != ""'`)
---
## 3. 프론트엔드 상세
### 3.1 template.ts — API 클라이언트 (~40줄)
| 함수 | 반환 | 설명 |
|---|---|---|
| `getTemplateList(params?)` | `Record<string, any>` | 목록 (list + total_count) |
| `getTemplateInfo(templateId)` | `Record<string, any> \| null` | 상세 (Template JSON 전체) |
| `insertTemplate(data)` | `Record<string, any>` | 생성 → `{ template_id }` 반환 |
| `updateTemplate(templateId, data)` | `void` | 수정 |
| `publishTemplate(templateId)` | `void` | 게시 |
| `deleteTemplate(templateId)` | `void` | 삭제 |
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>` (덕일 스타일)
### 3.2 developer.css — IDE 스타일 테마 (~350줄)
mockup `09-developer.css` 기반 React 포팅. v5 코스믹이 아닌 IDE/Figma 스타일.
**색상 체계 (CSS 변수 `--d-*`):**
| 변수 | 다크 | 라이트 | 용도 |
|---|---|---|---|
| `--d-bg` | #121218 | #f5f5f8 | 기본 배경 |
| `--d-bg2` | #1a1a22 | #ededf2 | 패널 배경 |
| `--d-bg3` | #22222c | #e4e4ec | 입력 배경 |
| `--d-surface` | #2a2a36 | #fff | 호버 배경 |
| `--d-border` | #3a3a48 | #d8d8e2 | 기본 보더 |
| `--d-text` | #e8e8ee | #1a1a24 | 기본 텍스트 |
| `--d-accent` | #5b9ef5 | #3b7dd8 | 액센트 (블루) |
| `--d-red` | #f87171 | #dc2626 | 위험/필수 |
| `--d-green` | #4ade80 | #16a34a | 성공/검색 |
**주요 클래스:**
- `.dev-shell` — 전체 셸 (flex column, 100vh)
- `.dev-hdr` — 헤더 (42px)
- `.dev-toolbar` — 도구모음 (34px)
- `.dev-palette` — 좌측 팔레트 (180px)
- `.dev-canvas` — 중앙 캔버스 (도트 그리드 배경)
- `.dev-props` — 우측 속성 패널 (260px)
- `.dev-block` — 캔버스 위 블록 (점선 보더, 선택 시 solid + 글로우)
- `.dev-status` — 하단 상태바 (22px)
- `.dev-popup-overlay/frame` — 등록/수정 팝업 편집 프레임
**폰트 사이즈**: 0.36rem(배지) ~ 0.72rem(로고) — mockup 컴팩트 스케일 그대로
### 3.3 useBuilderState.ts — Zustand 상태관리 (~280줄)
`create<BuilderState>()(devtools(...))` 패턴 (기존 tabStore와 동일).
**상태:**
| 키 | 타입 | 설명 |
|---|---|---|
| `tableName` | `string \| null` | 선택된 테이블 |
| `fields` | `FieldConfig[]` | 현재 테이블의 필드 목록 |
| `currentView` | `'list' \| 'create' \| 'edit'` | 현재 뷰 탭 |
| `blocks` | `Record<BuilderView, Component[]>` | 뷰별 블록 목록 |
| `selectedBlockId` | `string \| null` | 선택된 블록 |
| `connections` | `Connection[]` | DataPort 연결 목록 |
| `templateId` | `string \| null` | 저장된 템플릿 ID |
| `templateName` | `string` | 템플릿 이름 |
| `category` | `string` | 분류 |
| `description` | `string` | 설명 |
| `isDirty` | `boolean` | 변경 여부 |
**액션 (17개):**
| 액션 | 설명 |
|---|---|
| `setTable(name, fields)` | 테이블 선택 → fields 로드 |
| `switchView(view)` | 뷰 탭 전환 (선택 해제) |
| `addBlock(type, position)` | 블록 추가 (기본 config/size/label 자동 설정) |
| `removeBlock(id)` | 블록 삭제 (연결도 함께 제거) |
| `updateBlock(id, updates)` | 블록 업데이트 |
| `selectBlock(id)` | 블록 선택 |
| `moveBlock(id, x, y)` | 블록 이동 (min 0) |
| `resizeBlock(id, w, h)` | 블록 리사이즈 (min 40x20) |
| `updateBlockConfig(id, config)` | 타입별 config 업데이트 |
| `updateField(column, updates)` | FieldConfig 속성 변경 |
| `setTemplateMeta(meta)` | 템플릿 이름/분류/설명 변경 |
| `addConnection(conn)` | DataPort 연결 추가 |
| `removeConnection(connId)` | 연결 제거 |
| `toTemplate()` | 현재 상태 → Template JSON (저장용) |
| `fromTemplate(tpl)` | Template JSON → 상태 복원 (로드용) |
| `resetBuilder()` | 초기화 |
| `markClean()` | isDirty=false |
**셀렉터 훅:**
- `useCurrentViewBlocks()` — 현재 뷰의 블록 목록
- `useSelectedBlock()` — 선택된 블록 객체
**컴포넌트 기본 설정 (`defaultConfig`):**
| ComponentType | 기본 config |
|---|---|
| `table` | pageSize:20, selectionMode:'single', autoLoad:true, style:'default' |
| `form` | columns:2, saveAction:{method:'UPSERT', refreshAfterSave:true} |
| `search` | dateRangeEnabled:true, showResetButton:true, autoSearch:false, layout:'inline' |
| `button` | text:'버튼', actionType:'save', variant:'default' |
| `button-bar` | buttons:[{등록/primary}, {삭제/destructive}] |
| `title` | text:'제목', fontSize:'0.75rem', fontWeight:'700', align:'left' |
| `stats` | items:[] |
| `divider` | style:'solid' |
| `pagination` | pageSize:20, showSizeSelector:true, sizeOptions:[10,20,50,100] |
**컴포넌트 기본 크기 (`defaultSize`):**
| ComponentType | W × H |
|---|---|
| `table` | 854 × 380 |
| `form` | 440 × 300 |
| `search` | 854 × 42 |
| `button` | 100 × 36 |
| `button-bar` | 370 × 36 |
| `title` | 300 × 36 |
| `pagination` | 854 × 24 |
### 3.4 useBlockDrag.ts — 드래그/리사이즈 훅 (~90줄)
mousedown → document.mousemove → mouseup 패턴.
- `startDrag(e, id, origX, origY, origW, origH)` — 블록 이동 시작
- `startResize(e, id, origX, origY, origW, origH)` — 리사이즈 시작
- **Shift 키**: 8px 스냅 (mockup의 `Math.round(n/8)*8` 그대로)
- mouseup 시 리스너 자동 정리
- `document.body.style.cursor/userSelect` 드래그 중 설정/해제
### 3.5 BuilderLayout.tsx — 3패널 셸 (~55줄)
```
┌─ BuilderToolbar (헤더 + 도구모음) ─────────────────────┐
├──────────┬────────────────────────────┬──────────────────┤
│ Palette │ Canvas │ Props │
│ (180px) │ (flex:1) │ (260px) │
├──────────┴────────────────────────────┴──────────────────┤
│ 상태바: 블록 N개 · 테이블명 · 연결 N개 │
└─────────────────────────────────────────────────────────┘
```
**키보드 단축키:**
- `Delete` / `Backspace` — 선택된 블록 삭제
- `Escape` — 블록 선택 해제
### 3.6 BuilderToolbar.tsx — 헤더 + 도구모음 (~130줄)
**헤더 (dev-hdr):**
- INVYONE 로고 + DEV 배지
- 템플릿 이름 입력
- 저장 버튼
**도구모음 (dev-toolbar):**
- 테이블 드롭다운 (getMetaTableList → has_custom_meta ★ 표시)
- 뷰 탭 (목록 / 등록 / 수정)
- 수정됨 표시 (isDirty)
**저장 흐름:**
1. `toTemplate()` → Template JSON 생성
2. `templateId` 있으면 `updateTemplate()`, 없으면 `insertTemplate()`
3. 성공 시 `markClean()` + templateId 저장
### 3.7 BuilderPalette.tsx — 좌측 팔레트 (~75줄)
8종 컴포넌트를 4개 섹션으로 분류:
| 섹션 | 컴포넌트 |
|---|---|
| 데이터 | 📊 데이터 테이블, 🔍 검색 필터 |
| 입력 | 📝 입력 폼 |
| 액션 | 🔘 버튼, ⬜ 버튼 바 |
| 표시 | 📌 제목/텍스트, 📈 통계 카드, ── 구분선, 📄 페이지네이션 |
- **드래그앤드롭**: `onDragStart``component-type` 데이터 전달 → 캔버스에서 `onDrop`
- **클릭 추가**: 테이블 미선택 시 data 컴포넌트 비활성화 (opacity 0.4)
### 3.8 BuilderCanvas.tsx — 중앙 캔버스 (~80줄)
- **목록 뷰**: 전체 캔버스에 블록 자유 배치 (min 1200×800, 도트 그리드 배경)
- **등록/수정 뷰**: `dev-popup-overlay` + `dev-popup-frame` (500px 너비) 안에 블록 배치
- **드롭 처리**: `onDrop` → 마우스 좌표 계산 → `addBlock(type, {x, y})`
- **빈 캔버스**: 안내 메시지 표시 ("팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요")
- **선택 해제**: 캔버스 빈 공간 클릭 시 `selectBlock(null)`
### 3.9 BuilderBlock.tsx — 개별 블록 (~160줄)
**구조:**
```
.dev-block (position:absolute, 점선 보더)
├── .dev-block-label (블록 이름)
├── .dev-block-content (타입별 프리뷰)
└── .dev-resize-handle (우하단 리사이즈 핸들)
```
**타입별 프리뷰 (BlockPreview 내부 컴포넌트):**
| 타입 | 프리뷰 내용 |
|---|---|
| `table` | `<table>` 헤더 + 3행 더미 (visible 필드 최대 8개) |
| `form` | CSS Grid (columns 수) + 필드 라벨/입력 (최대 10개, required * 표시) |
| `search` | 가로 나열 필드 라벨/입력 (searchable 필드 최대 5개) + 검색 버튼 |
| `title` | fontSize/fontWeight/align 적용된 텍스트 |
| `button` | variant 스타일 적용된 단일 버튼 |
| `button-bar` | 버튼 목록 가로 나열 |
| `pagination` | 총 건수 / 페이지 번호 / 건수 선택기 |
| `divider` | 수평선 |
| `stats` | "통계 카드 프리뷰" 텍스트 |
**FieldOption 렌더링**: `string | {value, label}` 유니온 타입 처리 (tsc 에러 수정)
### 3.10 BuilderProps.tsx — 우측 속성 패널 (~90줄)
**공통 속성 (모든 블록):**
- 컴포넌트 종류 (아이콘 + 한글 라벨)
- 이름 (input, 캔버스 라벨과 양방향 동기화)
- 위치·크기 (X/Y/W/H 4칸 그리드, 캔버스와 양방향)
**타입별 분기:**
- `table` → TableProps
- `form` → FormProps
- `search` → SearchProps
- `button` → SingleButtonProps
- `button-bar` → ButtonBarProps
- `title` → TitleProps
**공통 삭제 버튼**: 하단 빨간 테두리 버튼
### 3.11 FieldListEditor.tsx — 필드 체크리스트 (~110줄)
table/form/search 속성 패널에서 공통으로 사용하는 필드 목록.
**기능:**
- 체크박스 토글 (`visible` 또는 `searchable` 속성)
- 필드 배지 표시 (PK, 필수, 검색, SYS, 계산)
- 타입 배지 (text, number, date 등)
- **클릭하면 상세 펼침** (FieldDetail 패널):
- 표시 이름, 너비 편집
- 토글: 필수, 편집, 검색, 정렬 (dev-toggle 스타일)
### 3.12 타입별 속성 패널
**TableProps.tsx** (~70줄):
- 페이지 크기 (10/20/50/100)
- 선택 방식 (없음/단일/다중)
- 자동 로드, 인라인 편집, 체크박스 토글
- 스타일 (기본/줄무늬/테두리/컴팩트)
- FieldListEditor (visible 토글)
**FormProps.tsx** (~40줄):
- 컬럼 수 (1/2/3칸)
- 저장 방식 (등록/수정/등록+수정)
- FieldListEditor (visible 토글)
**SearchProps.tsx** (~50줄):
- 날짜 범위 검색, 초기화 버튼, 자동 검색 토글
- 레이아웃 (인라인/세로)
- FieldListEditor (searchable 토글)
**ButtonProps.tsx** (~140줄):
- `SingleButtonProps`: 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지
- `ButtonBarProps`: 버튼 목록 CRUD (추가/삭제/편집), 각 버튼 액션+스타일 설정
**TitleProps.tsx** (~40줄):
- 텍스트, 크기(4단계), 굵기(4단계), 정렬(3종)
---
## 4. DB 변경
### 4.1 templates 테이블 (신규)
```sql
CREATE TABLE templates (
template_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
category VARCHAR(50),
description TEXT,
primary_table VARCHAR(100) NOT NULL,
fields JSONB NOT NULL,
views JSONB NOT NULL,
connections JSONB DEFAULT '[]',
company_code VARCHAR(20) NOT NULL DEFAULT '*',
version INTEGER DEFAULT 1,
status VARCHAR(20) DEFAULT 'draft',
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
```
**인덱스 3개:**
- `idx_templates_company` (company_code)
- `idx_templates_table` (primary_table)
- `idx_templates_status` (status)
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
### 4.2 application.yml DB 접속 변경
```
변경 전: jdbc:postgresql://39.117.244.52:11132/testvex (pw: ph0909!!)
변경 후: jdbc:postgresql://211.115.91.141:11134/test_dev (pw: vexplor0909!!)
```
39.117 서버 폐기에 따른 변경.
---
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `template.xml` |
| XML namespace: 파일명과 동일 | ✅ — `namespace="template"` |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig/Component만 예외 |
| 개발자 모드 CSS: IDE 스타일 (v5 코스믹 아님) | ✅ — `--d-*` 변수 체계 |
| Zustand devtools 미들웨어 | ✅ |
| 기존 apiClient 사용 | ✅ |
---
## 6. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `./gradlew bootJar` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (빌더 관련) | 에러 0개 |
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 3개 성공 |
---
## 7. Phase 3 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| 메타 드리프트 감지 (빌더에서 Template 열 때 최신 메타 비교) | Phase 3+ | 설계서 Section 9에 정의됨 |
| DataPort 연결 UI (속성 패널에서 연결 추가/삭제) | Phase 3+ | mockup의 `propsConnections()` 참고 |
| 기존 Template 목록/로드 UI | Phase 3+ | 현재는 새 템플릿 생성만 |
| 자동생성 + 프리셋 (⚡ 버튼) | Phase 6 | 설계서 명시 |
| stats/chart 타입 속성 패널 | Phase 3+ | 현재 빈 구조만 |
| 필드 순서 드래그 재정렬 | Phase 3+ | 현재 order 기준 정렬만 |
| Template 게시 워크플로우 UI | Phase 4 | 대시보드에서 게시된 Template 사용 |
---
## 8. 빌더 사용 흐름
```
1. /admin/builder 접속
2. 도구모음에서 테이블 선택 (예: sales_order_mng)
→ Phase 1 API로 FieldConfig[] 자동 로드
3. 팔레트에서 컴포넌트 선택 → 캔버스에 드래그 또는 클릭
(예: 제목 → 검색 필터 → 데이터 테이블 → 페이지네이션)
4. 캔버스에서 블록 드래그(이동) / 우하단 핸들로 리사이즈
5. 블록 클릭 → 우측 속성 패널에서 설정 조정
(필드 ON/OFF, 페이지 크기, 선택 모드 등)
6. 뷰 탭으로 목록/등록/수정 전환
(등록/수정은 팝업 프레임 안에서 편집)
7. 💾 저장 → Template JSON이 DB에 저장
8. 기존 Template 로드 → fromTemplate()으로 상태 복원
```
@@ -0,0 +1,400 @@
# Phase 4: 대시보드(=메뉴) — 사용자 화면 시스템
> **목적**: 사용자가 대시보드(=메뉴)를 만들고, 개발자가 만든 Template을 카드로 배치하여 업무 화면을 완성하는 시스템 구현
> **전제 조건**: Phase 1~3 완료 (FieldConfig API + 규격 컴포넌트 + 개발자 빌더에서 Template 생성 가능)
> **산출물**: 대시보드 CRUD + 사이드바 메뉴 자동 등록 + 템플릿 카드 배치 캔버스 + 사용자 오버라이드
> **다음 단계**: Phase 5에서 카드 간 비즈니스 룰/데이터 흐름 설정 (제어 모드)
---
## 1. 핵심 개념
**대시보드 = 메뉴 = 화면.** "대시보드"라는 이름이지만, 통계/차트 모아놓는 곳이 아니다.
```
사용자가 "수주관리" 대시보드 생성
→ 사이드바 메뉴에 "수주관리" 자동 등록
→ 캔버스에 수주관리 Template 카드 배치
→ 화면 완성!
→ 비즈니스 로직 필요하면 → Phase 5 (제어 모드)
```
---
## 2. 사용자 흐름 (mockup 기준)
mockup의 `index.html` + `js/05-state.js` + `js/01-shell.js` 참조.
### 2.1 대시보드 생성
1. 사이드바 하단의 **`+ 새 대시보드`** 클릭
2. 이름 입력 (예: "수주관리")
3. → 사이드바 메뉴에 자동 등록
4. → 빈 캔버스가 열림 ("아직 템플릿이 없습니다")
### 2.2 템플릿 카드 배치
1. 캔버스 상단의 **`+ 템플릿 추가`** 클릭
2. **템플릿 라이브러리 모달** 열림:
- 좌측: 카테고리 (영업, 생산, 인사, 재고, 관리자...)
- 우측: 해당 카테고리의 Template 카드 목록
- 검색 가능
3. Template 카드 클릭 → 캔버스에 추가
4. 여러 Template을 추가하여 한 대시보드(=화면)에 여러 카드 배치 가능
### 2.3 카드 조작
- **편집 모드** (우상단 편집 버튼) 켜면:
- 카드 드래그 (자유 배치, snap 없음)
- 카드 리사이즈 (우하단 핸들)
- 카드 삭제 (X 버튼)
- **카드 접기** (▼ 버튼) → 미니 KPI 뷰로 축소
- **카드 설정** (⚙ 버튼) → 사용자 오버라이드:
- 컬럼 표시/숨김
- 검색 필터 표시/숨김
- 통계 카드 표시/숨김
### 2.4 저장/복원
- 대시보드 레이아웃(카드 위치/크기/설정) 자동 저장
- 다음 접속 시 그대로 복원
---
## 3. 데이터 모델
### 3.1 대시보드 (DB 테이블)
**`dashboards` 테이블 (신규 생성):**
```sql
CREATE TABLE dashboards (
dashboard_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
icon VARCHAR(10) DEFAULT '📋',
display_order INTEGER DEFAULT 0,
company_code VARCHAR(20) NOT NULL,
user_id VARCHAR(50), -- NULL이면 회사 공통
is_active VARCHAR(1) DEFAULT 'Y',
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dashboards_company ON dashboards(company_code);
CREATE INDEX idx_dashboards_user ON dashboards(user_id);
```
### 3.2 대시보드 카드 배치
**`dashboard_cards` 테이블 (신규 생성):**
```sql
CREATE TABLE dashboard_cards (
card_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
template_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
position_x INTEGER DEFAULT 0, -- 캔버스 상 X 좌표 (px)
position_y INTEGER DEFAULT 0, -- 캔버스 상 Y 좌표 (px)
width INTEGER DEFAULT 600, -- 카드 너비 (px)
height INTEGER DEFAULT 400, -- 카드 높이 (px)
is_collapsed BOOLEAN DEFAULT FALSE, -- 접힌 상태 여부
display_order INTEGER DEFAULT 0,
is_active VARCHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_dcards_dashboard ON dashboard_cards(dashboard_id);
```
### 3.3 사용자 오버라이드
**`user_overrides` 테이블 (신규 생성):**
```sql
CREATE TABLE user_overrides (
override_id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
card_id VARCHAR(50) NOT NULL, -- ★ DB FK 제약조건 안 걸음
overrides JSONB NOT NULL, -- { fields: {...}, fieldOrder: [...], gridColumns: {...} }
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, card_id)
);
-- ★ card_id 기준 — 같은 템플릿을 두 번 올려도 카드별로 다른 오버라이드 가능
```
**overrides JSON 구조** (로우코드 플랫폼 SPEC Section 3.2):
```json
{
"fields": {
"fax_number": { "visible": false },
"customer_name": { "label": "고객사" }
},
"fieldOrder": ["order_date", "customer_name", "amount"],
"gridColumns": {
"order_id": { "width": 80 },
"customer_name": { "width": 200 }
}
}
```
**오버라이드 병합 규칙:**
1. 개발자 Template의 FieldConfig[]이 기본값
2. 사용자 override가 있으면 해당 필드만 덮어씀
3. 개발자가 필드를 삭제하면 해당 override는 자동 무시
---
## 4. 백엔드 API 설계
### 4.1 대시보드 CRUD
```
GET /api/dashboards → 목록 (해당 유저 + 회사 공통)
POST /api/dashboards → 생성 (이름, 아이콘)
PUT /api/dashboards/{dashboard_id} → 수정 (이름, 아이콘, 순서)
DELETE /api/dashboards/{dashboard_id} → 삭제
```
### 4.2 대시보드 카드 CRUD
```
GET /api/dashboards/{dashboard_id}/cards → 해당 대시보드의 카드 목록 (+ Template 기본 정보)
POST /api/dashboards/{dashboard_id}/cards → 카드 추가 (templateId, 위치/크기)
PUT /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 이동/리사이즈/접기
DELETE /api/dashboards/{dashboard_id}/cards/{card_id} → 카드 제거
PUT /api/dashboards/{dashboard_id}/cards/batch → 다수 카드 일괄 업데이트 (위치/크기 저장)
```
### 4.3 사이드바 메뉴 (= 대시보드 목록)
```
GET /api/sidebar/menu → 사이드바에 표시할 메뉴 목록
```
Response:
```json
{
"data": [
{
"section": "내 대시보드",
"items": [
{ "dashboardId": "dash-1", "name": "수주관리", "icon": "📦", "order": 1 },
{ "dashboardId": "dash-2", "name": "인사 대시보드", "icon": "👥", "order": 2 }
]
}
]
}
```
### 4.4 템플릿 라이브러리 (카드 추가 시 모달)
```
GET /api/templates/library → 게시된(published) 템플릿 목록 (카테고리별)
```
### 4.5 사용자 오버라이드
```
GET /api/overrides?card_id=xxx → 해당 유저+카드의 오버라이드
PUT /api/overrides → 오버라이드 저장 (card_id 기준)
```
### 4.6 Java 파일
**★ 패턴: 전부 BaseService + Map<String, Object>. 엔티티 클래스/DTO 없음.**
| 파일 | 역할 |
|---|---|
| `DashboardController.java` | 대시보드 + 카드 CRUD |
| `DashboardService.java` | `extends BaseService`, sqlSession으로 Map 기반 CRUD |
| `DashboardMapper.xml` | 대시보드/카드 SQL (`resultType="map"`, `parameterType="map"`) |
| `SidebarController.java` | 사이드바 메뉴 조회 |
| `UserOverrideController.java` | 사용자 오버라이드 CRUD |
| `UserOverrideService.java` | `extends BaseService`, 오버라이드 병합 로직 |
**파일명: `dashboard.xml` (namespace="dashboard"), `userOverride.xml` (namespace="userOverride")**
```java
// DashboardService.java (덕일 스타일)
@Service
@Slf4j
public class DashboardService extends BaseService {
@Autowired
private CommonService commonService;
public List<Map<String, Object>> getDashboardList(Map<String, Object> params) {
return sqlSession.selectList("dashboard.getDashboardList", params);
}
public Map<String, Object> getDashboardInfo(Map<String, Object> params) {
return sqlSession.selectOne("dashboard.getDashboardInfo", params);
}
public List<Map<String, Object>> getDashboardCardList(Map<String, Object> params) {
return sqlSession.selectList("dashboard.getDashboardCardList", params);
}
@Transactional
public Map<String, Object> insertDashboard(Map<String, Object> params) {
sqlSession.insert("dashboard.insertDashboard", params);
return params;
}
@Transactional
public void updateDashboard(Map<String, Object> params) {
sqlSession.update("dashboard.updateDashboard", params);
}
@Transactional
public int deleteDashboard(Map<String, Object> params) {
return sqlSession.update("dashboard.deleteDashboard", params);
}
@Transactional
public void updateCardPositions(Map<String, Object> params) {
List<Map<String, Object>> cards = (List<Map<String, Object>>) params.get("cards");
for (Map<String, Object> card : cards) {
sqlSession.update("dashboard.updateCardPosition", card);
}
}
}
```
---
## 5. 프론트엔드 구현
### 5.1 파일 구조
```
frontend/components/dashboard/
├── DashboardCanvas.tsx ← 캔버스 (카드 자유 배치)
├── DashboardCard.tsx ← 개별 카드 (Template 렌더링)
├── DashboardToolbar.tsx ← 상단 툴바 (편집/저장/+템플릿)
├── DashboardEmpty.tsx ← 빈 대시보드 안내
├── TemplateLibraryModal.tsx ← 템플릿 라이브러리 모달
├── CardSettingsPanel.tsx ← 카드 설정 패널 (사용자 오버라이드)
└── CardMiniView.tsx ← 접힌 카드의 미니 KPI 뷰
frontend/components/layout/
├── AppLayout.tsx ← (기존) 전체 레이아웃 — 수정 필요
├── Sidebar.tsx ← 사이드바 (대시보드 목록 = 메뉴)
└── Header.tsx ← 헤더
frontend/stores/
└── dashboardStore.ts ← 대시보드 상태 (Zustand)
frontend/lib/api/
├── dashboard.ts ← 대시보드 CRUD API
└── override.ts ← 사용자 오버라이드 API
```
### 5.2 DashboardCanvas — 핵심 컴포넌트
```typescript
/**
* ★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
* ★ 유일하게 타입이 있는 건 FieldConfig, Template 등 (invyone-component.ts 규격)
*
* 카드 데이터 구조 (Record<string, any>):
* card_id, template_id, template(로드된 Template), x, y, width, height, is_collapsed
*/
```
**카드 안에서 Template 렌더링:**
- Template.views.list의 components[]를 순서대로 렌더
- 각 Component는 Phase 2의 FcTable/FcForm/FcSearch를 사용
- Template.fields를 각 컴포넌트에 전달
- Template.connections로 컴포넌트 간 DataPort 연결
### 5.3 Sidebar — 대시보드 = 메뉴
```typescript
/**
* ★ Props도 별도 인터페이스 불필요 — 인라인 또는 Record<string, any> 사용
* 사이드바는 dashboards(Record<string, any>[]), active_dashboard_id, 콜백들을 props로 받음
*/
```
mockup 참조:
- `css/02-shell.css``.side`, `.si`, `.side-add-btn`
- `js/05-state.js``renderSidebar()`, `addDashboard()`, `switchDashboard()`
- 접힌 상태 (60px) + 툴팁
- hover 시 이름 변경/삭제 아이콘
### 5.4 TemplateLibraryModal
mockup 참조: `css/06-modals.css``.lib-modal`, `js/01-shell.js``openLib()/closeLib()`
```
┌──────────────────────────────────────────────┐
│ 템플릿 라이브러리 [검색...] [X] │
├────────────┬─────────────────────────────────┤
│ 카테고리 │ 카드 그리드 │
│ │ │
│ 전체 │ ┌───────┐ ┌───────┐ ┌───────┐ │
│ 영업/CRM │ │ 📦 │ │ 📊 │ │ 👥 │ │
│ 생산/공정 │ │수주관리 │ │매출KPI │ │인사관리│ │
│ 인사/급여 │ │sales │ │chart │ │hr │ │
│ 재고/물류 │ └───────┘ └───────┘ └───────┘ │
│ 관리자 │ │
└────────────┴─────────────────────────────────┘
```
---
## 6. 스타일
사용자 화면은 **v5 Cosmic Glassmorphism** (개발자 빌더의 IDE 스타일과 다름):
- 글래스모피즘: `backdrop-filter:blur(20px) saturate(1.4)` + `var(--glass)`
- 코스믹 배경: 별/성운/입자 (다크 모드), 구름형 (라이트 모드)
- 보라/시안/핑크 액센트
- 컴팩트 폰트 (0.55~0.85rem)
mockup 참조:
- `css/01-tokens.css` (토큰)
- `css/02-shell.css` (사이드바, 헤더)
- `css/03-canvas.css` (캔버스, 카드)
- `css/04-settings.css` (카드 설정)
- `css/06-modals.css` (라이브러리 모달)
- `frontend/styles/v5-layout.css` (React 포팅된 v5 토큰)
---
## 7. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` | **대시보드 전체 UI (진실의 원천)** |
| `notes/gbpark/2026-04-08-invyone-mockup/js/05-state.js` | 대시보드 상태 관리 로직 (SEED_STATE, renderCanvas, switchDashboard 등) |
| `notes/gbpark/2026-04-08-invyone-mockup/js/01-shell.js` | 테마/모드/사이드바/아바타/라이브러리 모달 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/02-canvas.js` | 편집/드래그/리사이즈/접기/삭제 |
| `notes/gbpark/2026-04-08-invyone-mockup/js/04-templates.js` | templateRenderers, buildCardEl, addCardFromLib |
| `notes/gbpark/2026-04-08-invyone-mockup/css/01-tokens.css` ~ `06-modals.css` | v5 스타일 전체 |
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` (Section 3.2) | 사용자 오버라이드 레이어 설계 |
| `frontend/styles/v5-layout.css` | React 포팅된 v5 CSS |
| `frontend/components/layout/AppLayout.tsx` | 기존 레이아웃 (수정 대상) |
---
## 8. 완료 기준
1. 사이드바에 대시보드 목록이 표시되고, 클릭 시 해당 대시보드로 전환된다
2. `+ 새 대시보드` → 이름 입력 → 사이드바에 자동 등록된다
3. 대시보드 이름 변경/삭제가 동작한다
4. `+ 템플릿 추가` → 라이브러리 모달에서 Template 선택 → 캔버스에 카드 추가된다
5. 편집 모드에서 카드 드래그/리사이즈가 동작한다
6. 카드 접기/펴기 (미니 KPI 뷰)가 동작한다
7. 카드 안에서 실제 데이터가 렌더링된다 (FcTable/FcSearch 등)
8. 카드 설정(⚙)에서 컬럼 ON/OFF → 실시간 반영
9. 대시보드 레이아웃이 DB에 저장/복원된다
10. 사용자 오버라이드가 Template 위에 올바르게 적용된다
---
## 9. 다음 단계 연결
Phase 5 (제어 모드):
- 대시보드의 카드를 클릭하면 → 해당 카드의 데이터 흐름 시각화
- 카드 간 비즈니스 룰 설정 (카드 A의 데이터가 변경되면 카드 B 자동 갱신 등)
- 대시보드 캔버스 위에 제어 모드 오버레이
@@ -0,0 +1,297 @@
# Phase 4 구현 작업기록 — 대시보드(=메뉴) 사용자 화면 시스템
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase4-dashboard-menu.md`
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
---
## 1. 생성/수정된 파일 (19개)
### 1.1 백엔드 (6개)
| 파일 | 상태 | 역할 |
|---|---|---|
| `backend-spring/src/main/resources/mapper/dashboard.xml` | 수정 (VEX 레거시 교체) | 대시보드/카드/사이드바 SQL 12쿼리 |
| `backend-spring/src/main/resources/mapper/userOverride.xml` | 신규 | 사용자 오버라이드 UPSERT/조회/삭제 3쿼리 |
| `backend-spring/src/main/java/com/erp/service/DashboardService.java` | 수정 (VEX 레거시 교체) | BaseService + sqlSession 덕일 스타일 |
| `backend-spring/src/main/java/com/erp/service/UserOverrideService.java` | 신규 | JSONB 파싱/직렬화 + UPSERT |
| `backend-spring/src/main/java/com/erp/controller/DashboardController.java` | 수정 (VEX 레거시 교체) | `/api/dashboards` 엔드포인트 |
| `backend-spring/src/main/java/com/erp/controller/UserOverrideController.java` | 신규 | `/api/overrides` 엔드포인트 |
### 1.2 프론트엔드 (13개)
| 파일 | 역할 |
|---|---|
| `frontend/lib/api/dashMenu.ts` | 대시보드/카드/사이드바 API 래퍼 |
| `frontend/lib/api/override.ts` | 사용자 오버라이드 API 래퍼 |
| `frontend/stores/dashboardStore.ts` | Zustand 대시보드 상태관리 |
| `frontend/styles/dashboard.css` | v5 Cosmic Glassmorphism 대시보드 CSS |
| `frontend/components/dash/DashboardLayout.tsx` | 전체 레이아웃 (사이드바+캔버스+모달) |
| `frontend/components/dash/DashboardSidebar.tsx` | 대시보드 목록 사이드바 |
| `frontend/components/dash/DashboardToolbar.tsx` | 상단 툴바 (편집/저장/+템플릿) |
| `frontend/components/dash/DashboardCanvas.tsx` | 자유 배치 캔버스 + 드래그/리사이즈 |
| `frontend/components/dash/DashboardCard.tsx` | 카드 (Template→FcTable/FcSearch 렌더) |
| `frontend/components/dash/DashboardEmpty.tsx` | 빈 대시보드 안내 |
| `frontend/components/dash/TemplateLibraryModal.tsx` | 템플릿 라이브러리 모달 |
| `frontend/components/dash/CardSettingsPanel.tsx` | 카드 설정 패널 (컬럼 ON/OFF) |
| `frontend/components/dash/CardMiniView.tsx` | 접힌 카드 미니 뷰 |
| `frontend/app/(main)/dash/page.tsx` | `/dash` 라우트 페이지 |
---
## 2. VEX 레거시 교체 사항
### 2.1 기존 DashboardService/Controller (VEX)
**교체 전**: JdbcTemplate 직접 사용, `dashboards`+`dashboard_elements` VEX 테이블, `/api/dashboard` (단수)
**교체 후**: BaseService + sqlSession 덕일 스타일, `DASHBOARDS`+`DASHBOARD_CARDS` 테이블, `/api/dashboards` (복수)
기존 VEX 대시보드의 유틸리티 엔드포인트 (`execute-query`, `execute-dml`, `table-schema`, `fetch-external-api`)는 제거됨. 필요 시 별도 유틸리티 컨트롤러로 분리 가능.
### 2.2 프론트엔드 경로 분리
| 구분 | VEX 레거시 | Phase 4 (INVYONE) |
|---|---|---|
| API 파일 | `lib/api/dashboard.ts` (유지) | `lib/api/dashMenu.ts` (신규) |
| 컴포넌트 | `components/dashboard/` (유지) | `components/dash/` (신규) |
| 페이지 | `app/(main)/dashboard/` (유지) | `app/(main)/dash/` (신규) |
VEX 레거시 파일은 건드리지 않음. 나중에 VEX 완전 폐기 시 삭제.
---
## 3. 백엔드 상세
### 3.1 dashboard.xml (namespace="dashboard", 12쿼리)
| 쿼리 ID | 용도 |
|---|---|
| `getDashboardList` | 대시보드 목록 (유저+회사공통, 페이지네이션) |
| `getDashboardListCnt` | 목록 카운트 |
| `getDashboardInfo` | 대시보드 단건 |
| `insertDashboard` | 대시보드 생성 |
| `updateDashboard` | 대시보드 수정 (이름/아이콘/순서) |
| `deleteDashboard` | 대시보드 소프트 삭제 (IS_ACTIVE='D') |
| `getDashboardCardList` | 카드 목록 (TEMPLATES JOIN으로 기본 정보 포함) |
| `insertDashboardCard` | 카드 추가 |
| `updateDashboardCard` | 카드 업데이트 (위치/크기/접기) |
| `updateCardPosition` | 카드 일괄 위치 업데이트 (단건, for loop용) |
| `deleteDashboardCard` | 카드 소프트 삭제 |
| `getSidebarMenu` | 사이드바 메뉴 (간략 대시보드 목록) |
### 3.2 userOverride.xml (namespace="userOverride", 3쿼리)
| 쿼리 ID | 용도 |
|---|---|
| `getUserOverride` | 유저+카드 기준 오버라이드 조회 |
| `upsertUserOverride` | ON CONFLICT UPSERT (UNIQUE(user_id, card_id)) |
| `deleteUserOverride` | 오버라이드 삭제 |
### 3.3 DashboardService.java (~100줄)
덕일 스타일 준수: `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
- ID 생성: `dash_` + UUID 12자 / `card_` + UUID 12자
- 기본값: icon=📋, position_x=50, width=600, height=400
### 3.4 DashboardController.java (~120줄)
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/dashboards` | 목록 (keyword, page, limit) |
| `GET` | `/api/dashboards/{id}` | 단건 |
| `POST` | `/api/dashboards` | 생성 |
| `PUT` | `/api/dashboards/{id}` | 수정 |
| `DELETE` | `/api/dashboards/{id}` | 삭제 |
| `GET` | `/api/dashboards/{id}/cards` | 카드 목록 (Template JOIN) |
| `POST` | `/api/dashboards/{id}/cards` | 카드 추가 |
| `PUT` | `/api/dashboards/{id}/cards/{cardId}` | 카드 수정 |
| `DELETE` | `/api/dashboards/{id}/cards/{cardId}` | 카드 삭제 |
| `PUT` | `/api/dashboards/{id}/cards/batch` | 카드 일괄 위치 업데이트 |
| `GET` | `/api/dashboards/sidebar/menu` | 사이드바 메뉴 |
### 3.5 UserOverrideController.java (~40줄)
| 메서드 | 엔드포인트 | 설명 |
|---|---|---|
| `GET` | `/api/overrides?card_id=xxx` | 조회 |
| `PUT` | `/api/overrides` | UPSERT |
| `DELETE` | `/api/overrides?card_id=xxx` | 삭제 |
---
## 4. 프론트엔드 상세
### 4.1 dashMenu.ts — API 래퍼 (~55줄)
```
getDashboardList, getDashboardInfo, insertDashboard, updateDashboard, deleteDashboard
getDashboardCards, insertDashboardCard, updateDashboardCard, deleteDashboardCard
updateCardPositionsBatch, getSidebarMenu
```
★ 전부 `Record<string, any>` — 별도 인터페이스 정의 안 함
### 4.2 dashboardStore.ts — Zustand (~80줄)
상태: `dashboards[]`, `activeDashboardId`, `cards[]`, `editMode`, `loading`
액션: `setDashboards`, `setActiveDashboard`, `setCards`, `addCard`, `updateCard`, `removeCard`, `toggleEditMode`, `addDashboard`, `updateDashboardInList`, `removeDashboard`
### 4.3 DashboardLayout.tsx — 전체 오케스트레이터 (~180줄)
```
┌──────────────────────────────────────────────────┐
│ DashboardLayout │
├────────────┬─────────────────────────────────────┤
│ Sidebar │ Toolbar │
│ (220px) ├─────────────────────────────────────┤
│ - 목록 │ Canvas │
│ - +추가 │ - DashboardCard × N │
│ │ - 드래그/리사이즈 │
│ │ + CardSettingsPanel (조건부) │
├────────────┴─────────────────────────────────────┤
│ TemplateLibraryModal (조건부) │
└──────────────────────────────────────────────────┘
```
### 4.4 DashboardCanvas.tsx — 드래그/리사이즈 (~130줄)
mockup의 `02-canvas.js` 로직 포팅:
- mousedown → 드래그 또는 리사이즈 모드 판별
- mousemove → 캔버스 경계 clamp 적용
- mouseup → store에 최종 위치 반영
- snap 없음 (px 단위), 캔버스 밖 방지
### 4.5 DashboardCard.tsx — Template 렌더 (~160줄)
카드 안에서 Phase 2 컴포넌트가 실제 데이터로 동작:
1. `primaryTable``getMetaFields()` → FieldConfig[] 로드
2. `fcList()` → 실제 데이터 조회
3. `FcSearch` + `FcTable` + `FcPagination` 렌더
4. 검색 → 재조회, 페이지네이션 → 재조회
### 4.6 TemplateLibraryModal.tsx (~130줄)
mockup의 `06-modals.css` 스타일 포팅:
- 좌측 카테고리 (7종: 전체/영업/생산/인사/재고/재무/관리자)
- 우측 템플릿 카드 그리드 (auto-fill, minmax 180px)
- 검색 필터
- 클릭 → `onSelectTemplate` 콜백
### 4.7 CardSettingsPanel.tsx (~100줄)
- 필드 visible 토글 (ON/OFF)
- `getUserOverride` + `upsertUserOverride` 실시간 저장
- JSONB overrides 구조: `{ fields: { column: { visible: bool } } }`
### 4.8 dashboard.css — v5 토큰 전부 사용 (~350줄)
| 용도 | 토큰 |
|---|---|
| 배경 | `var(--v5-glass)`, `var(--v5-surface)` |
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border-subtle)` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 강조 | `var(--v5-primary)`, `var(--v5-primary-light)` |
| 글로우 | `var(--v5-glow-sm)`, `var(--v5-glow-md)` |
| 유리 | `backdrop-filter: blur(20px) saturate(1.4)` |
다크/라이트 모드 변형: `.dark` 선택자 사용 (mockup 패턴 동일)
---
## 5. DB 테이블 (✅ 생성 완료)
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
### 5.1 VEX 레거시 처리
기존 `dashboards` 테이블(VEX 스키마: id, title, description, tags, settings 등)이 있어서 **`dashboards_vex_backup`으로 rename** 후 Phase 4 테이블 신규 생성.
### 5.2 생성된 테이블 3개 + 인덱스 3개
| 테이블 | PK | 용도 |
|---|---|---|
| `DASHBOARDS` | DASHBOARD_ID (VARCHAR 50) | 대시보드 (=메뉴 항목) |
| `DASHBOARD_CARDS` | CARD_ID (VARCHAR 50) | 대시보드 위 카드 배치 (위치/크기) |
| `USER_OVERRIDES` | OVERRIDE_ID (VARCHAR 50) | 사용자별 카드 오버라이드 (JSONB) |
**DASHBOARDS 컬럼**: DASHBOARD_ID, NAME, ICON, DISPLAY_ORDER, COMPANY_CODE, USER_ID, IS_ACTIVE, CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
**DASHBOARD_CARDS 컬럼**: CARD_ID, DASHBOARD_ID, TEMPLATE_ID, POSITION_X, POSITION_Y, WIDTH, HEIGHT, IS_COLLAPSED, DISPLAY_ORDER, IS_ACTIVE, CREATED_DATE, UPDATED_DATE
**USER_OVERRIDES 컬럼**: OVERRIDE_ID, USER_ID, CARD_ID, OVERRIDES(JSONB), CREATED_DATE, UPDATED_DATE + `UNIQUE(USER_ID, CARD_ID)`
**인덱스**: `idx_dashboards_company`, `idx_dashboards_user`, `idx_dcards_dashboard`
★ DB FK 제약조건 안 걸음 (앱 레벨 관리, Phase 4 설계서 Section 3 참조)
---
## 6. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `dashboard.xml`, `userOverride.xml` |
| XML namespace: 파일명과 동일 | ✅ |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
---
## 7. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (Phase 4 파일) | 에러 0개 |
| 기존 VEX 코드 에러 | Phase 4와 무관 (기존 camelCase/snake_case 불일치 에러) |
---
## 8. 접속 경로
- 대시보드 메뉴 시스템: `/dash`
- 개발자 빌더: `/admin/builder` (Phase 3)
- 테스트 페이지: `/test-fc` (Phase 2)
---
## 9. Phase 4 범위 밖 (다음에 해야 할 것)
| 항목 | 담당 Phase | 비고 |
|---|---|---|
| AppLayout 사이드바 통합 (대시보드 목록을 기존 사이드바에 표시) | Phase 4+ | 현재는 `/dash` 페이지에 자체 사이드바 |
| 대시보드 순서 드래그 재정렬 | Phase 4+ | 현재 display_order는 수동 |
| Template 게시 워크플로우 (빌더에서 publish → 라이브러리에 표시) | Phase 3+ | 현재 빌더 publish API는 구현됨 |
| 카드 간 DataPort 연결 (제어 모드) | Phase 5 | 대시보드 캔버스 위에 오버레이 |
| 사용자 오버라이드 실시간 반영 (FcTable에 override 적용) | Phase 4+ | 현재 settings에서 toggle만 저장 |
| 카드 미니 KPI 뷰 (접었을 때 실제 데이터 집계) | Phase 4+ | 현재 템플릿 이름/분류만 표시 |
| 대시보드 아이콘 선택 UI | Phase 4+ | 현재 기본 📋 |
---
## 10. 사용 흐름
```
1. /dash 접속
2. 좌측 사이드바: 대시보드 목록 표시
3. "+ 새 대시보드" → 이름 입력 → 사이드바에 자동 등록
4. 대시보드 클릭 → 캔버스 표시
5. "편집" 버튼 → 편집 모드 (드래그/리사이즈)
6. "+ 템플릿 추가" → 라이브러리 모달 → 게시된 Template 선택
7. → 캔버스에 카드 추가 (FcTable/FcSearch가 실제 DB 데이터 표시)
8. 카드 ⚙ → 컬럼 ON/OFF (사용자 오버라이드)
9. 카드 ▼ → 접기 (미니 뷰)
10. "저장" → DB에 카드 위치/크기 일괄 저장
11. 다른 대시보드 클릭 → 전환 (편집 모드 자동 해제)
```
@@ -0,0 +1,357 @@
# Phase 5: 제어 모드 — 비즈니스 룰 / 데이터 흐름
> **목적**: 대시보드의 카드(Template) 간 비즈니스 룰과 데이터 흐름을 시각적으로 정의하는 제어 모드 구현
> **전제 조건**: Phase 1~4 완료 (DB 메타 + 컴포넌트 + 빌더 + 대시보드에 카드 배치까지 동작)
> **산출물**: 제어 모드 UI (SVG 연결선 + 테이블 노드 + 규칙 빌더) + 비즈니스 룰 엔진
> **다음 단계**: Phase 6 (자동생성/프리셋 — 편의 기능, 맨 마지막)
---
## 1. 핵심 개념
**제어 모드 = 같은 캔버스에서 "데이터가 어떻게 흐르는지" 보고 편집하는 모드**
일반 모드: 카드(화면)를 사용
제어 모드: 카드 간 관계/비즈니스 룰을 설정
```
[수주관리 카드] ──수주 확정──→ [발주관리 카드] ──금액>1000만──→ [프로젝트 카드]
(자동 등록) (조건분기)
```
---
## 2. 두 가지 기능
### 2.1 읽기 모드 — 흐름 시각화 (카드 클릭)
카드 클릭 → 해당 카드의 데이터 소스(테이블) + 관련 테이블 + 비즈니스 룰이 트리 형태로 표시
mockup 참조: `js/06-control-mode.js``showCardFlow()`, `enterControlMode()`
### 2.2 편집 모드 — 규칙 빌더 (드래그앤드롭)
팔레트에서 테이블 노드 + 제어 노드를 캔버스에 드래그, I/O 포트로 연결
mockup 참조: `js/07-rule-builder.js``dropTable()`, `dropControl()`, `initPortEvents()`
---
## 3. 읽기 모드 상세
### 3.1 진입
캔버스 상단 툴바의 **`⚡ 제어`** 버튼 클릭 → 제어 모드 진입
### 3.2 시각적 변화
1. 캔버스 격자가 시안 톤으로 변경 (`rgba(0,206,201,.22)`)
2. 모든 카드 반투명 (opacity 0.5)
3. 편집 모드 자동 비활성화
### 3.3 카드 클릭 → 흐름 표시
1. 클릭된 카드만 좌측 고정 + opacity 1, 나머지 fade out (0.08)
2. 카드의 `data-source-table`에서 소스 테이블 추출
3. Phase 1의 `GET /api/meta/tables/{tableName}/relations`로 관계 조회
4. BFS로 도달 가능한 전체 체인 계산
5. **트리 확산 애니메이션**: 선이 그려짐 → 노드 reveal → 또 선 → 또 노드 (depth별 지연)
### 3.4 시각 요소
**테이블 노드** (mockup: `.tbl-node`):
```
┌─ 🏢 DEPARTMENT ─── 4컬럼 ──┐
│ ● dept_code VARCHAR PK │
│ ○ dept_name VARCHAR │
│ ○ company_code VARCHAR FK │
│ ○ parent_dept VARCHAR FK │
└────────────────────────────┘
```
**연결선 4종** (SVG bezier):
| 타입 | 색상 | CSS 클래스 | 용도 |
|---|---|---|---|
| 소스 | 핑크 (#fd79a8) | `ctrl-line-tpl` | 카드 → 소스 테이블 |
| 자동실행 | 보라 (#6c5ce7) | `ctrl-line-auto` | 테이블 A → 테이블 B 자동 등록 |
| 조건분기 | 앰버 (#fdcb6e) | `ctrl-line-cond` | 조건 충족 시 실행 |
| FK | 시안 (#00cec9) | `ctrl-line` | 외래키 관계 (기본 비표시, 비즈니스 룰만) |
**연결선 위 뱃지** (mockup: `.ctrl-badge`):
- 클릭 가능, 해당 룰의 상세 정보 표시
- 조건분기 뱃지: Yes/No 분기 경로 표시
### 3.5 라이트 모드 보정
라이트 모드에서 연결선은 더 진하게 (배경 대비):
- 시안 → `#00a89e`, 보라 → `#5b4acf`, 앰버 → `#d4a017`, 핑크 → `#e0559e`
---
## 4. 규칙 빌더 상세
### 4.1 제어 노드 16종
mockup의 `CTRL_NODE_TYPES` (js/07-rule-builder.js):
| 카테고리 | 노드 | 아이콘 | RGB | 특수 출력 포트 |
|---|---|---|---|---|
| 트리거 | 타이머 | ⏱ | 0,206,201 | — |
| 조건 | 조건분기 | ◇ | 253,203,110 | Yes/No |
| 조건 | 데이터 검증 | ✔ | 255,107,129 | Pass/Fail |
| 액션 | 상태 변경 | 🔄 | 108,92,231 | — |
| 액션 | 자동 등록 | 📝 | 85,239,196 | — |
| 액션 | 계산/수식 | 🧮 | 45,152,218 | — |
| 액션 | 삭제/보관 | 🗑 | 255,71,87 | — |
| 액션 | 문서 생성 | 📄 | 162,155,254 | — |
| 흐름 | 승인/결재 | ✋ | 255,165,2 | Approved/Rejected |
| 흐름 | 대기/지연 | ⏳ | 72,219,251 | — |
| 흐름 | 반복 | 🔁 | 223,142,254 | Each/Done |
| 흐름 | 병렬 실행 | 🔀 | 0,206,201 | — |
| 흐름 | 병합/합류 | ⤵ | 149,175,192 | — |
| 연동 | 외부 호출 | 🌐 | 116,185,255 | — |
| 연동 | 알림 발송 | 📨 | 253,121,168 | — |
| 기록 | 로그 기록 | 📜 | 150,150,160 | — |
### 4.2 노드 구조
```
┌─ [In] ──────────────────── [Out] ──┐
│ 📝 자동 등록 │
│ ───────────────────────── │
│ 클릭하여 설정 │
│ (대상 테이블, 필드 매핑) │ [Yes]
│ │ [No]
└────────────────────────────────────┘
```
각 노드에 I/O 포트:
- **Input 포트** (좌측): 데이터/이벤트를 받음
- **Output 포트** (우측): 결과를 내보냄
- 조건분기/승인 등은 다중 output (Yes/No)
### 4.3 포트 연결 인터랙션
1. output 포트 mousedown → 연결선 드래그 시작 (SVG 임시선)
2. input 포트 위에서 mouseup → 연결 완료
3. 연결 중간에 삭제 뱃지 (hover 시 표시)
4. 같은 노드끼리 연결 금지, 중복 연결 금지
### 4.4 노드 설정 팝오버
노드 body 클릭 → 설정 팝오버:
| 노드 타입 | 설정 항목 |
|---|---|
| 자동 등록 | 대상 테이블, 필드 매핑 (소스→대상), 조건 |
| 상태 변경 | 대상 테이블, 대상 필드, 변경값 |
| 조건분기 | 조건식 (필드, 연산자, 값) |
| 승인/결재 | 결재선, 승인자 |
| 타이머 | 실행 주기, 시작 조건 |
| 외부 호출 | URL, 메서드, 파라미터 매핑 |
| 알림 발송 | 대상 (사용자/이메일/슬랙), 메시지 템플릿 |
| 계산/수식 | 수식 (computed 문법), 대상 필드 |
---
## 5. 데이터 모델
### 5.1 비즈니스 룰 DB
**`business_rules` 테이블 (신규 생성):**
```sql
CREATE TABLE business_rules (
rule_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50), -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
name VARCHAR(200),
description TEXT,
nodes JSONB NOT NULL, -- 노드 배열 (위치, 타입, 설정)
connections JSONB NOT NULL, -- 연결 배열 (from → to)
is_enabled BOOLEAN DEFAULT TRUE,
company_code VARCHAR(20) NOT NULL,
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
```
### 5.2 노드 JSON 구조
```
★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
★ JSONB로 저장되므로 구조만 문서화
노드 (Record<string, any>):
id, type(CTRL_NODE_TYPES 키 또는 'table'), label, x, y,
config(타입별 설정), table_name(table 노드일 때)
연결 (Record<string, any>):
id, from_node_id, from_port('out'|'yes'|'no'|'each'|'done'),
to_node_id, to_port('in')
비즈니스 룰 (Record<string, any>):
rule_id, dashboard_id, name, nodes(배열), connections(배열), is_enabled
```
---
## 6. 백엔드 API
### 6.1 관계 조회 (★ table_relationships 기반 — Phase 1과 소스 분리)
```
GET /api/meta/tables/{tableName}/relations → 테이블 간 업무 관계
```
**★ 2소스 책임 분리:**
- Phase 1 `getTableFields()``table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
- Phase 5 `getMetaRelations()``table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
### 6.2 비즈니스 룰 CRUD
```
GET /api/dashboards/{dashboard_id}/rules → 해당 대시보드의 룰 목록
GET /api/rules/{rule_id} → 룰 상세 (노드+연결)
POST /api/dashboards/{dashboard_id}/rules → 룰 생성
PUT /api/rules/{rule_id} → 룰 수정
DELETE /api/rules/{rule_id} → 룰 삭제
PUT /api/rules/{rule_id}/toggle → 활성/비활성 토글
```
**★ 덕일 스타일 3레이어. 파일명 1:1 매칭.**
| Java 파일 | XML | namespace |
|---|---|---|
| `BusinessRuleController.java` | `businessRule.xml` | `businessRule` |
| `BusinessRuleService.java` | | |
```java
@Service
@Slf4j
public class BusinessRuleService extends BaseService {
@Autowired
private CommonService commonService;
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
commonService.applyPagination(params);
int totalCount = sqlSession.selectOne("businessRule.getBusinessRuleListCnt", params);
List<Map<String, Object>> list = sqlSession.selectList("businessRule.getBusinessRuleList", params);
return commonService.buildListResponse(list, totalCount, params);
}
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
return sqlSession.selectOne("businessRule.getBusinessRuleInfo", params);
}
@Transactional
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
// nodes, connections → ObjectMapper로 JSON 문자열 변환 후 #{nodes}::jsonb
sqlSession.insert("businessRule.insertBusinessRule", params);
return params;
}
@Transactional
public void updateBusinessRule(Map<String, Object> params) {
sqlSession.update("businessRule.updateBusinessRule", params);
}
@Transactional
public int deleteBusinessRule(Map<String, Object> params) {
return sqlSession.update("businessRule.deleteBusinessRule", params);
}
}
```
---
## 7. 프론트엔드 구현
### 7.1 파일 구조
```
frontend/components/control/
├── ControlMode.tsx ← 제어 모드 오버레이 (캔버스 위에)
├── ControlToolbar.tsx ← 제어 모드 툴바 (읽기/편집 전환)
├── FlowViewer.tsx ← 읽기 모드: 카드 흐름 시각화
├── RuleBuilder.tsx ← 편집 모드: 규칙 빌더
├── TableNode.tsx ← 테이블 노드 UI
├── ControlNode.tsx ← 제어 노드 UI (16종)
├── NodeConfigPopover.tsx ← 노드 설정 팝오버
├── PortHandle.tsx ← I/O 포트 (드래그 연결)
├── ConnectionLine.tsx ← SVG bezier 연결선
├── ControlPalette.tsx ← 제어 모드 팔레트 (사이드바 교체)
└── hooks/
├── useControlMode.ts ← 제어 모드 상태 관리
├── usePortDrag.ts ← 포트 연결 드래그 로직
└── useFlowAnimation.ts ← 트리 확산 애니메이션
```
### 7.2 SVG 연결선 렌더링
mockup의 bezier 곡선 방식 그대로:
```typescript
// from 좌표 (x1,y1) → to 좌표 (x2,y2)
const dx = x2 - x1;
const d = `M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`;
// SVG <path d={d} class="ctrl-line-auto" marker-end="url(#arr-auto)" />
```
선이 그려지는 애니메이션: `stroke-dashoffset` transition
---
## 8. 스타일
제어 모드 전용 스타일은 mockup의 `css/07-control-mode.css` + `css/08-rule-builder.css` 참조.
핵심:
- 캔버스 격자: 시안 톤 (`rgba(0,206,201,.22)`)
- 테이블 노드: 시안 보더 + 글래스 배경
- 연결선: 점선 + 펄스 애니메이션 (`stroke-dasharray: 6 3`)
- 뱃지: 글래스 + 시안/보라/앰버 보더
---
## 9. 참고 파일
| 파일 | 용도 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | **흐름 시각화 (진실의 원천)** — 996줄, enterControlMode, showCardFlow, buildCtrlTree, calcFlowPositions |
| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | **규칙 빌더 (진실의 원천)** — 752줄, 16종 노드, 포트 드래그, 연결 관리, 설정 팝오버 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/07-control-mode.css` | 제어 모드 스타일 |
| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 규칙 빌더 스타일 |
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 9 Phase 3) | 제어 플로우 로드맵 |
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 비즈니스 룰 정의 |
---
## 10. 완료 기준
1. **제어 모드 진입/탈출**: ⚡ 버튼으로 토글, 캔버스 시각이 변함
2. **카드 클릭 → 흐름 표시**: 소스 테이블 + 관련 테이블 + 비즈니스 룰이 트리로 표시
3. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
4. **4종 연결선**: 소스(핑크), 자동(보라), 조건(앰버), FK(시안) 구분
5. **팔레트에서 노드 드래그앤드롭**: 테이블 노드 + 제어 노드 캔버스에 배치
6. **포트 연결**: output → input 드래그로 연결, SVG bezier 곡선
7. **노드 설정 팝오버**: 타입별 설정 폼이 동작
8. **규칙 저장/로드**: DB에 저장하고 다시 열면 복원
9. **빈 영역 클릭 → 흐름 닫기**
10. **다크/라이트 모드 지원**
---
## 11. 다음 단계 연결
Phase 6 (자동생성/프리셋):
- 테이블 선택 → FieldConfig 기반으로 Template 자동 생성
- 프리셋 3종 (basic/split/tabs) 자동 배치
- 이 시점에서 모든 기능이 갖춰져 있으므로, 자동생성이 올바른 Template JSON을 생성할 수 있음
@@ -0,0 +1,368 @@
# Phase 5 구현 작업기록 — 제어 모드 (비즈니스 룰 / 데이터 흐름)
> **작업일**: 2026-04-10
> **설계서**: `notes/gbpark/2026-04-10-phase5-control-mode.md`
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
---
## 1. 생성/수정된 파일 (22개)
### 1.1 백엔드 (6개)
| 파일 | 상태 | 역할 |
|---|---|---|
| `backend-spring/src/main/resources/mapper/meta.xml` | 수정 | `getMetaRelations` 쿼리 추가 (table_relationships) |
| `backend-spring/src/main/java/com/erp/service/MetaService.java` | 수정 | `getMetaRelations()` 메서드 추가 |
| `backend-spring/src/main/java/com/erp/controller/MetaController.java` | 수정 | `GET /api/meta/tables/{tableName}/relations` 엔드포인트 |
| `backend-spring/src/main/resources/mapper/businessRule.xml` | 신규 | 비즈니스 룰 CRUD 7쿼리 (namespace=`businessRule`) |
| `backend-spring/src/main/java/com/erp/service/BusinessRuleService.java` | 신규 | 비즈니스 룰 서비스 (JSONB 파싱/직렬화) |
| `backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java` | 신규 | 룰 CRUD + 토글 엔드포인트 6개 |
### 1.2 프론트엔드 (16개)
| 파일 | 역할 |
|---|---|
| `frontend/lib/api/meta.ts` | `getMetaRelations()` 추가 |
| `frontend/lib/api/businessRule.ts` | 비즈니스 룰 CRUD API 클라이언트 (신규) |
| `frontend/styles/control-mode.css` | 제어 모드 + 규칙 빌더 CSS (~350줄, mockup 포팅) |
| `frontend/components/control/hooks/useControlMode.ts` | Zustand 상태관리 + `CTRL_NODE_TYPES` 16종 |
| `frontend/components/control/hooks/useFlowAnimation.ts` | BFS 체인 + 위치 계산 + 애니메이션 타이밍 |
| `frontend/components/control/hooks/usePortDrag.ts` | 포트 연결 드래그 로직 |
| `frontend/components/control/TableNode.tsx` | 테이블 노드 UI (컬럼 목록 + 드래그) |
| `frontend/components/control/ConnectionLine.tsx` | SVG bezier 연결선 4종 + 마커 + 뱃지 |
| `frontend/components/control/FlowViewer.tsx` | 읽기 모드: 카드 클릭 → 흐름 시각화 |
| `frontend/components/control/ControlNode.tsx` | 제어 노드 16종 (I/O 포트 포함) |
| `frontend/components/control/PortHandle.tsx` | I/O 포트 핸들 (드래그 연결 시작/끝) |
| `frontend/components/control/NodeConfigPopover.tsx` | 노드 설정 팝오버 (타입별 폼) |
| `frontend/components/control/ControlPalette.tsx` | 제어 팔레트 (사이드바 교체) |
| `frontend/components/control/RuleBuilder.tsx` | 편집 모드: 규칙 빌더 (드래그앤드롭) |
| `frontend/components/control/ControlToolbar.tsx` | 읽기/편집 모드 전환 + 저장 버튼 |
| `frontend/components/control/ControlMode.tsx` | 제어 모드 오버레이 메인 컴포넌트 |
### 1.3 기존 파일 수정 (3개)
| 파일 | 변경 내용 |
|---|---|
| `frontend/components/dash/DashboardToolbar.tsx` | ⚡ 제어 모드 토글 버튼 추가 (제어 진입 시 편집 모드 자동 해제) |
| `frontend/components/dash/DashboardLayout.tsx` | `ControlMode` 오버레이 통합 + 제어 편집 시 사이드바→팔레트 교체 |
| `frontend/components/dash/DashboardCanvas.tsx` | `forwardRef` + `control-mode` CSS 클래스 + 제어 모드 시 드래그 비활성화 |
---
## 2. 백엔드 상세
### 2.1 Meta Relations API (2소스 책임 분리)
```
GET /api/meta/tables/{tableName}/relations → table_relationships 기반
```
**★ Phase 1과 소스 분리:**
- Phase 1 `getMetaFields()``table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
- Phase 5 `getMetaRelations()``table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합침**
### 2.2 BusinessRule CRUD (덕일 스타일 3레이어)
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
| `/api/dashboards/{id}/rules` | GET | 대시보드별 룰 목록 (페이지네이션) |
| `/api/dashboards/{id}/rules` | POST | 룰 생성 (`rule_` + UUID 12자) |
| `/api/rules/{id}` | GET | 룰 상세 (JSONB → Object 파싱) |
| `/api/rules/{id}` | PUT | 룰 수정 (Object → JSON 직렬화) |
| `/api/rules/{id}` | DELETE | 룰 소프트 삭제 (IS_ACTIVE='D') |
| `/api/rules/{id}/toggle` | PUT | 활성/비활성 토글 |
**businessRule.xml 쿼리 7개:**
`getBusinessRuleList`, `getBusinessRuleListCnt`, `getBusinessRuleInfo`, `insertBusinessRule`, `updateBusinessRule`, `deleteBusinessRule`, `toggleBusinessRule`
**JSONB 처리**: `nodes`/`connections``ObjectMapper`로 파싱/직렬화 + `#{nodes}::jsonb` 캐스팅
---
## 3. 프론트엔드 상세
### 3.1 useControlMode — Zustand 상태관리 (~160줄)
| 상태 | 타입 | 설명 |
|---|---|---|
| `active` | boolean | 제어 모드 ON/OFF |
| `mode` | 'view' \| 'edit' | 읽기/편집 모드 |
| `activeFlowCardId` | string \| null | 흐름 표시 중인 카드 |
| `flowEdges` | Record[] | BFS 결과 엣지 배열 |
| `tablePositions` | Record | 테이블 노드 위치 |
| `ruleNodes` | Record[] | 규칙 빌더 노드 |
| `ruleConnections` | Record[] | 규칙 빌더 연결 |
| `configNodeId` | string \| null | 설정 팝오버 대상 노드 |
**CTRL_NODE_TYPES 16종 (mockup 그대로):**
| 카테고리 | 노드 | 아이콘 | 특수 포트 |
|---|---|---|---|
| 트리거 | 타이머 | ⏱ | — |
| 조건 | 조건분기 | ◇ | Yes/No |
| 조건 | 데이터 검증 | ✔ | Pass/Fail |
| 액션 | 상태 변경 | 🔄 | — |
| 액션 | 자동 등록 | 📝 | — |
| 액션 | 계산/수식 | 🧮 | — |
| 액션 | 삭제/보관 | 🗑 | — |
| 액션 | 문서 생성 | 📄 | — |
| 흐름 | 승인/결재 | ✋ | Approved/Rejected |
| 흐름 | 대기/지연 | ⏳ | — |
| 흐름 | 반복 | 🔁 | Each/Done |
| 흐름 | 병렬 실행 | 🔀 | — |
| 흐름 | 병합/합류 | ⤵ | — |
| 연동 | 외부 호출 | 🌐 | — |
| 연동 | 알림 발송 | 📨 | — |
| 기록 | 로그 기록 | 📜 | — |
### 3.2 FlowViewer — 읽기 모드 (카드 클릭 → 흐름 시각화)
**흐름:**
1. 캔버스에서 카드 클릭
2. `getMetaRelations(sourceTable)` → 업무 관계 조회
3. BFS로 도달 가능한 전체 체인 계산 (depth 무제한)
4. `calcFlowPositions()` — 카드 우측에 depth별 트리 배치 (colGap=270~350, rowGap=240)
5. `calcAnimationTimings()` — depth별 지연 (STEP=500ms, NODE_D=350ms)
6. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
**시각 효과:**
- 선택된 카드: opacity 1 + 좌측 고정 + 시안 보더
- 나머지 카드: opacity 0.08
- 테이블 노드: scale(0.3) → scale(1) 트랜지션
- 연결선: `stroke-dashoffset` draw 애니메이션 → pulse 복원
- 빈 영역 클릭: 흐름 닫기 (모든 카드 0.5로 복원)
- 노드 드래그: 실시간 위치 업데이트 + 선 재그리기
### 3.3 RuleBuilder — 편집 모드 (규칙 빌더)
**흐름:**
1. 사이드바 → 제어 팔레트 교체 (DB 테이블 + 제어 노드 16종)
2. 팔레트에서 캔버스로 드래그앤드롭 → 노드 생성
3. 노드 헤더 드래그 → 이동
4. output 포트 mousedown → bezier 임시선 → input 포트 mouseup → 연결 생성
5. 연결 중간 hover → 삭제 뱃지 (✕)
6. 노드 body 클릭 → 설정 팝오버 (타입별 폼)
7. "규칙 저장" → `insertBusinessRule()` / `updateBusinessRule()`
**포트 연결 규칙:**
- 같은 노드끼리 연결 금지
- 중복 연결 금지
- 드래그 중 모든 input 포트 pulse 애니메이션
### 3.4 SVG 연결선 4종
| 타입 | 색상 | CSS 클래스 | 마커 | 용도 |
|---|---|---|---|---|
| 소스 | 핑크 #fd79a8 | `ctrl-line-tpl` | arr-src | 카드 → 소스 테이블 |
| 자동실행 | 보라 #6c5ce7 | `ctrl-line-auto` | arr-auto | 테이블 → 테이블 자동 등록 |
| 조건분기 | 앰버 #fdcb6e | `ctrl-line-cond` | arr-cond | 조건 충족 시 실행 |
| FK | 시안 #00cec9 | `ctrl-line` | arr-fk | 외래키 관계 |
**라이트 모드 보정**: 시안→#00a89e, 보라→#5b4acf, 앰버→#d4a017, 핑크→#e0559e
### 3.5 연결선 위 뱃지
- 일반: 글래스 배경 + 시안 보더 + 라벨 텍스트
- 조건분기: 확장형 (`cb-head` + `cb-cond` + `cb-paths` Yes/No)
- 소스: 핑크 보더
- 자동실행: 보라 보더
### 3.6 NodeConfigPopover — 타입별 설정 폼
| 노드 타입 | 설정 항목 |
|---|---|
| 조건분기 | 필드, 연산자(=, ≠, >, <, 기한경과, 포함), 값 |
| 상태 변경 | 대상 테이블, 변경 필드, 변경값 |
| 자동 등록 | 대상 테이블 |
| 타이머 | 기준 필드, 경과량, 단위(일/시간/주) |
| 알림 발송 | 채널(이메일/SMS/푸시/Slack), 수신자, 메시지 |
| 승인/결재 | 승인자, 승인 조건 |
| 계산/수식 | 대상 테이블, 결과 필드, 수식 |
| 외부 호출 | URL, 메서드(POST/GET/PUT/DELETE) |
| 데이터 검증 | 대상 필드, 검증 규칙 |
| 로그 기록 | 내용 |
### 3.7 Dashboard 통합
**DashboardToolbar:**
- ⚡ 버튼 추가 (제어 모드 진입 시 편집 모드 자동 해제)
- 제어 모드 활성 중: 편집/템플릿추가/저장 버튼 숨김
**DashboardLayout:**
- `ControlMode` 오버레이 (ControlToolbar + FlowViewer/RuleBuilder)
- 제어 편집 모드: 사이드바 → `ControlPalette` 교체
**DashboardCanvas:**
- `forwardRef`로 부모에서 캔버스 DOM 참조
- `control-mode` CSS 클래스 (시안 격자 배경)
- 제어 모드 시 카드 드래그/리사이즈 비활성화
---
## 4. DB 변경
### 4.1 business_rules 테이블 (✅ 생성 완료)
```sql
CREATE TABLE business_rules (
rule_id VARCHAR(50) PRIMARY KEY,
dashboard_id VARCHAR(50),
name VARCHAR(200),
description TEXT,
nodes JSONB NOT NULL,
connections JSONB NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
company_code VARCHAR(20) NOT NULL,
created_by VARCHAR(50),
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active VARCHAR(1) DEFAULT 'Y'
);
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
```
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
★ DB FK 제약조건 안 걸음 (설계서 명시 — 앱 레벨 관리)
---
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
| 규칙 | 준수 |
|---|---|
| 3레이어 (Controller → Service → XML) | ✅ |
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
| BaseService 상속 | ✅ |
| @Autowired CommonService | ✅ |
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `businessRule.xml` |
| XML namespace: 파일명과 동일 | ✅ — `namespace="businessRule"` |
| SQL: UPPER_SNAKE | ✅ |
| SELECT 쉼표: 앞에 | ✅ |
| #{파라미터}: snake_case | ✅ |
| OGNL test: 바깥 작은따옴표 | ✅ |
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
| v5/ctrl CSS 변수 사용, 즉흥 값 금지 | ✅ |
| 컴팩트 폰트 사이즈 (0.42~0.68rem) | ✅ |
| mockup 진실의 원천 | ✅ — 06-control-mode.js, 07-rule-builder.js |
---
## 6. CSS 토큰 사용 목록
| 용도 | 토큰 |
|---|---|
| 시안 | `--ctrl-cyan` (#00cec9), `--ctrl-cyan-glow` |
| 보라 | `--ctrl-primary` (#6c5ce7) |
| 앰버 | `--ctrl-amber` (#fdcb6e) |
| 핑크 | `--ctrl-pink` (#fd79a8) |
| 그린 | `--ctrl-green` (#55efc4) |
| 레드 | `--ctrl-red` (#ff4757) |
| 유리 배경 | `--ctrl-glass`, `--ctrl-glass-strong` |
| 유리 보더 | `--ctrl-glass-border` |
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
| 보더 | `var(--v5-border)` |
| 서피스 | `var(--v5-surface)`, `var(--v5-surface-hover)` |
| 블러 | `backdrop-filter: blur(20px) saturate(1.4)` (v5 글래스 패턴) |
---
## 7. 검증 결과
| 검증 항목 | 결과 |
|---|---|
| `./gradlew compileJava` | BUILD SUCCESSFUL |
| `./gradlew bootJar` | BUILD SUCCESSFUL |
| `npx tsc --noEmit` (Phase 5 파일) | 에러 0개 |
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 성공 |
| 기존 레거시 에러 | 2827개 (Phase 5 무관, 변동 없음) |
---
## 8. 완료 기준 대비 상태
| # | 기준 | 상태 |
|---|---|---|
| 1 | ⚡ 버튼으로 제어 모드 토글 | ✅ |
| 2 | 캔버스 시각 변화 (시안 격자, 카드 반투명) | ✅ |
| 3 | 카드 클릭 → 흐름 표시 (소스 테이블 + 관련 테이블 + 비즈니스 룰 트리) | ✅ |
| 4 | 트리 확산 애니메이션 (선→노드 순차 등장) | ✅ |
| 5 | 4종 연결선 (소스/자동/조건/FK) | ✅ |
| 6 | 팔레트에서 노드 드래그앤드롭 | ✅ |
| 7 | 포트 연결 (output→input 드래그, bezier 곡선) | ✅ |
| 8 | 노드 설정 팝오버 (타입별 폼) | ✅ |
| 9 | 규칙 저장/로드 (DB JSONB) | ✅ |
| 10 | 빈 영역 클릭 → 흐름 닫기 | ✅ |
| 11 | 다크/라이트 모드 | ✅ (라이트 보정 CSS 포함) |
---
## 9. 접속 경로
- **대시보드 (제어 모드 포함)**: `/dash` → ⚡ 버튼 클릭
- 개발자 빌더: `/admin/builder` (Phase 3)
- 테스트 페이지: `/test-fc` (Phase 2)
---
## 10. Phase 5 범위 밖 (다음에 해야 할 것)
| 항목 | 비고 |
|---|---|
| `table_relationships` 테이블에 실제 데이터 INSERT | 현재 빈 테이블이면 흐름이 소스→1개만 표시 |
| 비즈니스 룰 실행 엔진 (트리거/조건 평가/액션 실행) | 현재는 시각 편집만 |
| 규칙 로드 UI (기존 규칙 목록에서 선택 → 복원) | 현재는 새 규칙 생성만 |
| computed 수식 파서 (AST 기반 안전한 파서) | Phase 5+ |
| DataPort 이벤트 버스 (카드 간 실시간 데이터 전달) | Phase 5+ |
| 자동생성/프리셋 (Phase 6) | 모든 기능 갖춰진 후 마지막 |
---
## 11. 파일별 코드 요약
### businessRule.xml (107줄)
```
7개 쿼리: getBusinessRuleList, getBusinessRuleListCnt, getBusinessRuleInfo,
insertBusinessRule, updateBusinessRule, deleteBusinessRule, toggleBusinessRule
common include: companyCodeFilter, dynamicOrderBy, pagination
```
### BusinessRuleService.java (~95줄)
```
퍼블릭: getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule,
updateBusinessRule, deleteBusinessRule, toggleBusinessRule
프라이빗: parseJsonField, stringifyJsonField (JSONB 유틸)
```
### BusinessRuleController.java (~100줄)
```
6개 엔드포인트: GET/POST rules + GET/PUT/DELETE/toggle rule
```
### useControlMode.ts (~160줄)
```
Zustand store: active, mode, flowEdges, ruleNodes, ruleConnections
+ CTRL_NODE_TYPES 16종 정의 + genNodeId/genConnId 헬퍼
```
### FlowViewer.tsx (~180줄)
```
카드 클릭 이벤트 → getMetaRelations → BFS → 위치 계산 → 순차 reveal
TableNode + ConnectionSvg + FlowLine + FlowBadge 렌더
```
### RuleBuilder.tsx (~180줄)
```
캔버스 드래그앤드롭 → 노드 생성 (테이블/제어)
포트 연결 SVG + 삭제 뱃지 + NodeConfigPopover
```
### control-mode.css (~350줄)
```
--ctrl-* 변수 체계, 연결선 4종 + pulse 애니메이션,
테이블 노드, 제어 노드, I/O 포트, 설정 팝오버, 팔레트
다크/라이트 모드 보정
```