This commit is contained in:
2026-04-09 00:39:11 +09:00
parent 3dad7fcf4f
commit c6e81c4520
9 changed files with 8033 additions and 1030 deletions
+678
View File
@@ -0,0 +1,678 @@
// ============================================================================
// INVYONE 컴포넌트 규격 v1.0 — TypeScript 타입 정의
// ============================================================================
//
// 설계 원칙:
// 1. 하나의 FieldConfig가 모든 곳(테이블/폼/검색)에서 쓰인다
// 2. 컴포넌트는 DataPort로 통신한다
// 3. 설정은 BaseConfig → TypeConfig → InstanceConfig 3단계
//
// 기존 vex 타입(types/component.ts, types/screen.ts)과 의존성 없음 — 완전 독립
// ============================================================================
// ─────────────────────────────────────────────────────────────────────────────
// 1. FieldConfig — 유일한 필드 규격
// ─────────────────────────────────────────────────────────────────────────────
/**
* 필드 렌더링 방식을 결정하는 타입.
*
* - 테이블에서는 셀 포맷, 폼에서는 입력 위젯, 검색에서는 필터 UI로 매핑된다.
* - 새 타입 추가 시 이 union에 리터럴만 추가하면 된다.
*/
export type FieldType =
| 'text' // 일반 문자열
| 'number' // 숫자 (포맷팅)
| 'date' // 날짜
| 'datetime' // 날짜+시간
| 'select' // 드롭다운 (options 배열)
| 'entity' // FK 참조 (팝업 검색)
| 'checkbox' // 체크박스
| 'textarea' // 장문
| 'file' // 파일 첨부
| 'code'; // 자동채번 (readonly)
/**
* entity 타입 필드의 FK 참조 정보.
* 팝업 검색 시 대상 테이블·표시 컬럼·검색 컬럼을 지정한다.
*/
export interface FieldRef {
/** 참조 대상 테이블 */
table: string;
/** 값으로 사용할 컬럼 (PK 등) */
valueColumn: string;
/** 화면에 표시할 컬럼 */
displayColumn: string;
/** 팝업 검색 대상 컬럼 목록 */
searchColumns?: string[];
}
/**
* 모든 컴포넌트가 공유하는 유일한 필드 정의.
*
* 테이블 컬럼, 폼 입력, 검색 조건 전부 이 하나의 규격으로 표현한다.
* 렌더러가 `type`을 보고 적절한 UI를 그린다.
*
* vex의 ColumnConfig(354줄) + FilterConfig + FormField → 이 ~30줄로 통합.
*/
export interface FieldConfig {
// ─── 식별 ───
/** DB 컬럼명 (필드의 유일 키) */
column: string;
/** 화면에 표시되는 라벨 */
label: string;
// ─── 타입 ───
/** 렌더링 방식을 결정하는 필드 타입 */
type: FieldType;
// ─── 표시 ───
/** 화면에 보이는지 여부 */
visible: boolean;
/** 표시 순서 (작을수록 먼저) */
order: number;
/** 컬럼 너비 (px, 테이블에서 사용) */
width?: number;
/** 텍스트 정렬 */
align?: 'left' | 'center' | 'right';
// ─── 입력 ───
/** 필수 입력 여부 */
required: boolean;
/** 편집 가능 여부 */
editable: boolean;
/** 기본값 */
defaultValue?: unknown;
/** 입력 힌트 텍스트 */
placeholder?: string;
// ─── 타입별 확장 ───
/** select 타입: 선택지 목록 */
options?: string[];
/** entity 타입: FK 참조 정보 */
ref?: FieldRef;
/** 포맷 문자열 (number: '#,##0', date: 'YYYY-MM-DD' 등) */
format?: string;
/** 자동 계산 수식 (예: 'quantity * unit_price') */
computed?: string;
// ─── 메타 ───
/** PK 여부 */
pk?: boolean;
/** 시스템 필드 여부 (company_code 등, 폼에서 숨김) */
system?: boolean;
/** 검색 대상 여부 */
searchable?: boolean;
/** 정렬 가능 여부 */
sortable?: boolean;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Component — 유일한 컴포넌트 규격
// ─────────────────────────────────────────────────────────────────────────────
/**
* 지원하는 컴포넌트 종류.
*
* 새 컴포넌트 추가 시 이 union에 리터럴을 추가하고,
* ComponentTypeConfigMap에 대응하는 설정 타입을 등록한다.
*/
export type ComponentType =
| 'table' // 데이터 테이블 (목록)
| 'form' // 입력 폼
| 'search' // 검색 필터
| 'button' // 버튼 (단일)
| 'button-bar' // 버튼 그룹
| 'tabs' // 탭
| 'split-panel' // 분할 패널
| 'title' // 텍스트/제목
| 'stats' // 통계 카드
| 'divider' // 구분선
| 'pagination'; // 페이지네이션
/**
* 그리드 위 컴포넌트의 위치·크기.
* 빌더가 드래그/리사이즈로 관리한다.
*/
export interface Position {
/** 가로 위치 (그리드 단위) */
x: number;
/** 세로 위치 (그리드 단위) */
y: number;
/** 너비 (그리드 단위) */
w: number;
/** 높이 (그리드 단위) */
h: number;
}
/**
* 반응형 브레이크포인트별 위치·크기 오버라이드.
* 기본 position을 기준으로, 각 브레이크포인트에서 달라지는 속성만 지정한다.
*/
export interface ResponsiveOverride {
/** 대형 화면 (≥1200px) */
lg?: Partial<Position>;
/** 중형 화면 (≥768px) */
md?: Partial<Position>;
/** 소형 화면 (<768px) */
sm?: Partial<Position>;
}
/**
* 컴포넌트의 데이터 소스 바인딩.
*/
export interface DataSource {
/** 바인딩 대상 테이블명 */
table: string;
/** 읽기/쓰기 모드 */
mode: 'read' | 'write' | 'readwrite';
}
/**
* 모든 컴포넌트의 기본 구조.
*
* 타입이 무엇이든 이 구조를 따른다.
* 타입별 고유 설정은 `config` 필드에 ComponentTypeConfig로 들어간다.
*/
export interface Component {
// ─── 식별 ───
/** 컴포넌트 고유 ID */
id: string;
/** 컴포넌트 종류 */
type: ComponentType;
/** 빌더에서 표시되는 이름 */
label: string;
// ─── 위치 (빌더가 관리) ───
/** 그리드 상의 위치·크기 */
position: Position;
// ─── 반응형 ───
/** 브레이크포인트별 위치·크기 오버라이드 */
responsive?: ResponsiveOverride;
// ─── 데이터 바인딩 ───
/** 데이터 소스 설정 */
dataSource?: DataSource;
/** 이 컴포넌트가 사용하는 필드 목록 (유일한 필드 규격) */
fields?: FieldConfig[];
// ─── 데이터 포트 (컴포넌트 간 통신) ───
/** 받는 데이터 포트 */
inputs?: DataPort[];
/** 내보내는 데이터 포트 */
outputs?: DataPort[];
// ─── 타입별 설정 ───
/** 컴포넌트 타입에 따른 고유 설정 */
config: ComponentTypeConfig;
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. DataPort — 컴포넌트 간 통신 규격
// ─────────────────────────────────────────────────────────────────────────────
/**
* DataPort가 전달하는 데이터의 형태.
*
* | 타입 | 실제 데이터 |
* |------|------------|
* | row | Record<string, any> (단일 행) |
* | rows | Record<string, any>[] (복수 행) |
* | value | any (단일 값) |
* | params | Record<string, any> (검색 파라미터) |
*/
export type DataPortType =
| 'row' // 단일 행: Record<string, any>
| 'rows' // 복수 행: Record<string, any>[]
| 'value' // 단일 값: any
| 'params'; // 검색 파라미터: Record<string, any>
/**
* 컴포넌트가 데이터를 주고받는 포트.
*
* output.name → input.name 매칭으로 연결된다.
* 빌더에서 시각적으로 연결을 설정하고, 실행 시 이벤트 버스가 자동 전달한다.
*/
export interface DataPort {
/** 포트 이름 (예: 'selectedRow', 'searchParams') */
name: string;
/** 데이터 형태 */
type: DataPortType;
/** 연결된 컴포넌트 ID (빌더에서 설정) */
connectedTo?: string;
}
/**
* 두 컴포넌트 간 DataPort 연결을 나타낸다.
*
* Template.connections 배열에 저장되어 화면 전체의 데이터 흐름을 정의한다.
*/
export interface Connection {
/** 연결 고유 ID */
id: string;
/** 출발점 (데이터를 보내는 쪽) */
from: {
/** 출발 컴포넌트 ID */
componentId: string;
/** 출발 output 포트 이름 */
port: string;
};
/** 도착점 (데이터를 받는 쪽) */
to: {
/** 도착 컴포넌트 ID */
componentId: string;
/** 도착 input 포트 이름 */
port: string;
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. ComponentTypeConfig — 타입별 설정
// ─────────────────────────────────────────────────────────────────────────────
// --- 4.1 table ---
/** 테이블 기본 정렬 설정 */
export interface DefaultSort {
/** 정렬 대상 컬럼명 */
column: string;
/** 정렬 방향 */
direction: 'asc' | 'desc';
}
/** 테이블 상단 툴바 표시 설정 */
export interface TableToolbar {
/** 엑셀 내보내기 버튼 표시 */
showExcel: boolean;
/** 새로고침 버튼 표시 */
showRefresh: boolean;
/** 필터 버튼 표시 */
showFilter: boolean;
}
/** 테이블 스타일 종류 */
export type TableStyle = 'default' | 'striped' | 'bordered' | 'compact';
/**
* 테이블(목록) 컴포넌트 설정.
*
* 기본 포트:
* - outputs: selectedRow(row), selectedRows(rows)
* - inputs: searchParams(params), refreshTrigger(value)
*/
export interface TableConfig {
/** 페이지당 행 수 (기본: 20) */
pageSize: number;
/** 행 선택 모드 */
selectionMode: 'none' | 'single' | 'multiple';
/** 체크박스 컬럼 표시 여부 */
showCheckbox: boolean;
/** 인라인 편집 활성화 여부 */
inlineEdit: boolean;
/** 화면 로드 시 자동으로 데이터 조회 */
autoLoad: boolean;
/** 기본 정렬 (미지정 시 서버 기본) */
defaultSort?: DefaultSort;
/** 상단 툴바 설정 */
toolbar: TableToolbar;
/** 테이블 스타일 */
style: TableStyle;
}
// --- 4.2 form ---
/** 폼 섹션 구분 (선택) */
export interface FormSection {
/** 섹션 라벨 */
label: string;
/** 이 섹션에 포함되는 필드의 column 목록 (FieldConfig.column 참조) */
fields: string[];
}
/** 폼 저장 액션 설정 */
export interface FormSaveAction {
/** 저장 방식 */
method: 'INSERT' | 'UPDATE' | 'UPSERT';
/** 저장 성공 후 표시 메시지 */
successMessage?: string;
/** 저장 후 목록 자동 새로고침 여부 */
refreshAfterSave: boolean;
}
/**
* 입력 폼 컴포넌트 설정.
*
* 기본 포트:
* - outputs: formData(row), savedRow(row)
* - inputs: loadRow(row)
*/
export interface FormConfig {
/** 폼 레이아웃 컬럼 수 */
columns: 1 | 2 | 3;
/** 섹션 구분 (미지정 시 단일 섹션) */
sections?: FormSection[];
/** 저장 액션 설정 */
saveAction: FormSaveAction;
}
// --- 4.3 search ---
/**
* 검색 필터 컴포넌트 설정.
*
* 기본 포트:
* - outputs: searchParams(params)
* - inputs: 없음
*/
export interface SearchConfig {
/** 날짜 범위 검색 활성화 (date 타입 필드를 자동으로 범위 입력으로) */
dateRangeEnabled: boolean;
/** 초기화 버튼 표시 */
showResetButton: boolean;
/** 입력 시 자동 검색 (디바운스) */
autoSearch: boolean;
/** 검색 필드 배치 방식 */
layout: 'inline' | 'stacked';
}
// --- 4.4 button / button-bar ---
/**
* 버튼 액션 종류 (12종).
*
* 새 액션 추가 시 이 union에 리터럴만 추가하고 핸들러를 구현하면 된다.
*/
export type ActionType =
| 'save' // 저장
| 'edit' // 수정 모드 전환
| 'delete' // 삭제
| 'add' // 신규 추가
| 'cancel' // 취소
| 'close' // 닫기
| 'navigate' // 페이지 이동
| 'popup' // 팝업 열기
| 'search' // 검색 실행
| 'reset' // 초기화
| 'submit' // 제출 (폼)
| 'approval'; // 승인
/** 버튼 스타일 종류 */
export type ButtonVariant = 'primary' | 'default' | 'destructive' | 'outline' | 'ghost';
/** 버튼의 제어 플로우 연결 설정 */
export interface ButtonFlow {
/** 연결할 플로우 ID */
flowId: string;
/** 플로우 실행 타이밍 (액션 전/후) */
timing: 'before' | 'after';
}
/**
* 단일 버튼 컴포넌트 설정.
*
* 기본 포트:
* - outputs: clicked(value)
* - inputs: 없음
*/
export interface ButtonConfig {
/** 버튼 텍스트 */
text: string;
/** 실행할 액션 종류 */
actionType: ActionType;
/** 버튼 스타일 */
variant: ButtonVariant;
/** 확인 메시지 (있으면 실행 전 확인 팝업 표시) */
confirm?: string;
/** 제어 플로우 연결 (선택) */
flow?: ButtonFlow;
}
/**
* 버튼 그룹 컴포넌트 설정.
* 여러 버튼을 한 줄로 묶어 배치한다.
*/
export interface ButtonBarConfig {
/** 포함된 버튼 목록 */
buttons: ButtonConfig[];
}
// --- 4.5 tabs ---
/** 개별 탭 항목 */
export interface TabItem {
/** 탭에 표시되는 라벨 */
label: string;
/** 탭 고유 ID */
id: string;
}
/**
* 탭 컴포넌트 설정.
*/
export interface TabsConfig {
/** 탭 목록 */
tabs: TabItem[];
/** 기본 선택 탭 ID */
defaultTab: string;
}
// --- 4.6 기타 (title, stats, divider, pagination, split-panel) ---
/**
* 제목/텍스트 컴포넌트 설정.
*/
export interface TitleConfig {
/** 표시 텍스트 */
text: string;
/** 글꼴 크기 (CSS 값, 예: '1.2rem') */
fontSize: string;
/** 글꼴 두께 (CSS 값, 예: '600') */
fontWeight: string;
/** 텍스트 정렬 */
align: 'left' | 'center' | 'right';
}
/** 통계 항목 하나 */
export interface StatsItem {
/** 항목 라벨 */
label: string;
/** 집계 대상 컬럼명 */
column: string;
/** 집계 방식 */
aggregation: 'count' | 'sum' | 'avg';
}
/**
* 통계 카드 컴포넌트 설정.
*
* 기본 포트:
* - outputs: 없음
* - inputs: data(rows)
*/
export interface StatsConfig {
/** 통계 항목 목록 */
items: StatsItem[];
}
/**
* 구분선 컴포넌트 설정.
*/
export interface DividerConfig {
/** 선 스타일 */
style: 'solid' | 'dashed' | 'dotted';
}
/**
* 페이지네이션 컴포넌트 설정.
*
* 기본 포트:
* - outputs: pageChange(params)
* - inputs: totalCount(value)
*/
export interface PaginationConfig {
/** 페이지당 행 수 */
pageSize: number;
/** 페이지 크기 선택기 표시 여부 */
showSizeSelector: boolean;
/** 선택 가능한 페이지 크기 목록 */
sizeOptions: number[];
}
/**
* 분할 패널 컴포넌트 설정.
* 추후 확장 예정 — 현재는 빈 구조로 예약.
*/
export interface SplitPanelConfig {
/** 분할 방향 */
direction?: 'horizontal' | 'vertical';
/** 초기 분할 비율 (0~1, 왼쪽/위쪽 비율) */
ratio?: number;
}
// --- ComponentTypeConfig 유니온 ---
/**
* 모든 컴포넌트 타입별 설정의 유니온 타입.
*
* Component.config 필드에 사용되며,
* Component.type에 따라 실제로 들어가는 타입이 결정된다.
*/
export type ComponentTypeConfig =
| TableConfig
| FormConfig
| SearchConfig
| ButtonConfig
| ButtonBarConfig
| TabsConfig
| TitleConfig
| StatsConfig
| DividerConfig
| PaginationConfig
| SplitPanelConfig;
/**
* ComponentType → ComponentTypeConfig 매핑.
*
* 타입 안전한 config 접근이 필요할 때 사용한다.
* 예: ComponentTypeConfigMap['table'] → TableConfig
*/
export interface ComponentTypeConfigMap {
table: TableConfig;
form: FormConfig;
search: SearchConfig;
button: ButtonConfig;
'button-bar': ButtonBarConfig;
tabs: TabsConfig;
'split-panel': SplitPanelConfig;
title: TitleConfig;
stats: StatsConfig;
divider: DividerConfig;
pagination: PaginationConfig;
}
/**
* 타입 안전한 컴포넌트.
*
* Component.type과 Component.config의 타입을 연동시킨다.
* 예: TypedComponent<'table'>이면 config는 반드시 TableConfig.
*/
export interface TypedComponent<T extends ComponentType> extends Omit<Component, 'type' | 'config'> {
type: T;
config: ComponentTypeConfigMap[T];
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. Template — 화면 단위
// ─────────────────────────────────────────────────────────────────────────────
/**
* 뷰 하나의 설정 (목록, 등록 팝업, 수정 팝업).
*
* edit 뷰가 create를 상속하면 extends: 'create'로 지정하고,
* 다른 부분만 오버라이드한다.
*/
export interface ViewConfig {
/** 이 뷰에 배치된 컴포넌트 목록 */
components: Component[];
/** 상속 대상 뷰 (edit가 create를 상속할 때) */
extends?: 'create';
/** 팝업 크기 (create/edit 뷰용) */
size?: {
/** 팝업 너비 (px) */
w: number;
/** 팝업 높이 (px) */
h: number;
};
}
/** 템플릿 상태 */
export type TemplateStatus = 'draft' | 'published';
/**
* 한 화면 = 한 템플릿.
*
* 목록 + 등록 팝업 + 수정 팝업이 하나의 Template에 포함된다.
* Template.fields가 유일한 진실의 원천이며, 모든 뷰가 이를 공유한다.
*/
export interface Template {
// ─── 식별 ───
/** 템플릿 고유 ID */
templateId: string;
/** 화면 이름 */
name: string;
/** 분류 (예: sales, production, purchase) */
category: string;
/** 화면 설명 */
description?: string;
// ─── 데이터 ───
/** 메인 바인딩 테이블 */
primaryTable: string;
/** 필드 정의 목록 — 모든 뷰가 이 하나를 공유한다 */
fields: FieldConfig[];
// ─── 3뷰 ───
/** 목록 / 등록 / 수정 세 가지 뷰 */
views: {
/** 목록 화면 */
list: ViewConfig;
/** 등록 팝업 */
create: ViewConfig;
/** 수정 팝업 (create 상속 가능) */
edit: ViewConfig;
};
// ─── 연결 ───
/** 컴포넌트 간 DataPort 연결 목록 */
connections: Connection[];
// ─── 메타 ───
/** 회사 코드 */
companyCode: string;
/** 템플릿 버전 */
version: number;
/** 상태 (초안 / 게시됨) */
status: TemplateStatus;
/** 생성 일시 (ISO 8601) */
createdAt: string;
/** 수정 일시 (ISO 8601) */
updatedAt: string;
}
@@ -0,0 +1,584 @@
# INVYONE 컴포넌트 규격 (v1.0)
> **상태**: DRAFT
> **작성일**: 2026-04-08
> **원칙**: 범용성, 호환성, 확장성
> **핵심 문제**: vex에서 ColumnConfig(354줄), FilterConfig, FormField가 전부 다른 규격 → 컴포넌트 간 데이터 전달 시 변환 필요 → 에러 폭발
---
## 1. 설계 원칙
### 1.1 하나의 FieldConfig가 모든 곳에서 쓰인다
vex의 문제:
```
table-list → ColumnConfig (354줄 types.ts)
form → 자체 필드 구조
search → FilterConfig
button → ButtonActionConfig
→ 같은 "수주일" 필드를 3군데서 3가지 다른 규격으로 정의
→ 테이블에서 폼으로 데이터 넘길 때 변환 필요
→ 변환 코드에서 에러 발생
```
invyone의 해법:
```
table → FieldConfig[] (컬럼으로 렌더)
form → FieldConfig[] (입력 필드로 렌더)
search → FieldConfig[] (검색 조건으로 렌더)
→ 같은 FieldConfig를 공유
→ 테이블 행 클릭 → 그대로 폼에 전달 → 변환 없음
```
### 1.2 컴포넌트는 DataPort로 통신한다
컴포넌트끼리 직접 참조하지 않는다. 대신 표준화된 DataPort로 데이터를 주고받는다.
```
[검색] --searchParams-→ [테이블] --selectedRow-→ [폼]
--selectedRows-→ [버튼(삭제)]
```
모든 컴포넌트가 같은 형식(`Record<string, any>`)의 데이터를 주고받으므로 변환 불필요.
### 1.3 설정은 3단계로 나뉜다
```
1. BaseConfig — 모든 컴포넌트 공통 (id, type, position, label)
2. TypeConfig — 타입별 고유 설정 (table의 pageSize, form의 columns)
3. InstanceConfig — 인스턴스별 오버라이드 (이 화면에서만 다르게)
```
---
## 2. FieldConfig — 유일한 필드 규격
```typescript
/**
* 모든 컴포넌트가 공유하는 필드 정의.
* 테이블 컬럼, 폼 입력, 검색 조건, 전부 이 하나로.
*/
interface FieldConfig {
// ─── 식별 ───
column: string; // DB 컬럼명 (유일 키)
label: string; // 표시 라벨
// ─── 타입 ───
type: FieldType; // 렌더링 방식 결정
// ─── 표시 ───
visible: boolean; // 보이는지
order: number; // 표시 순서
width?: number; // 컬럼 너비 (px, 테이블용)
align?: 'left' | 'center' | 'right';
// ─── 입력 ───
required: boolean; // 필수 여부
editable: boolean; // 편집 가능 여부
defaultValue?: any; // 기본값
placeholder?: string; // 플레이스홀더
// ─── 타입별 확장 (★ 이것만으로 모든 특수 케이스 커버) ───
options?: string[]; // select/category: 선택지
ref?: { // entity: FK 참조
table: string;
valueColumn: string;
displayColumn: string;
searchColumns?: string[];
};
format?: string; // number: '#,##0', date: 'YYYY-MM-DD'
computed?: string; // 자동 계산 수식: 'quantity * unit_price'
// ─── 메타 ───
pk?: boolean; // PK 여부
system?: boolean; // 시스템 필드 (company_code 등, 폼에서 숨김)
searchable?: boolean; // 검색 대상 여부
sortable?: boolean; // 정렬 가능 여부
}
type FieldType =
| 'text' // 일반 문자열
| 'number' // 숫자 (포맷팅)
| 'date' // 날짜
| 'datetime' // 날짜+시간
| 'select' // 드롭다운 (options 배열)
| 'entity' // FK 참조 (팝업 검색)
| 'checkbox' // 체크박스
| 'textarea' // 장문
| 'file' // 파일 첨부
| 'code'; // 자동채번 (readonly)
```
**이게 전부다.** vex의 ColumnConfig 354줄 → invyone의 FieldConfig ~30줄.
### 2.1 같은 FieldConfig가 다르게 렌더되는 방식
```
FieldConfig { column: 'order_date', label: '수주일', type: 'date' }
→ 테이블에서: <td>2026-04-08</td> (format 적용)
→ 폼에서: <DatePicker label="수주일" required />
→ 검색에서: <DateRangePicker label="수주일" />
→ 속성에서: [수주일] date ☑visible ☑required
```
렌더러가 FieldConfig의 `type`을 보고 알아서 적절한 UI를 그린다. 필드 규격은 하나.
### 2.2 렌더링 계약 (★ 스키마-런타임 바인딩 규칙)
**렌더러마다 FieldConfig를 해석하는 규칙이 다르다. 이 차이는 암묵적이면 안 되고 명시적이어야 한다.**
| FieldType | 테이블 렌더 | 폼 렌더 | 검색 렌더 |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<input type="text">` | `<input type="text">` (부분 일치) |
| `number` | format 적용 (#,##0) | `<input type="number">` | `<input type="number">` (범위: min~max) |
| `date` | format 적용 (YYYY-MM-DD) | DatePicker (단일) | **DateRangePicker (범위)** |
| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker |
| `select` | 텍스트 그대로 | `<select>` (단일 선택) | **MultiSelect (다중 선택)** |
| `entity` | ref.displayColumn 표시 | 검색 팝업 | 검색 팝업 (단일) |
| `checkbox` | ✓/✗ | `<checkbox>` | `<select>` (전체/✓/✗) |
| `textarea` | 말줄임 (…) | `<textarea>` | `<input type="text">` (부분 일치) |
| `file` | 파일명 링크 | 파일 업로드 | — (검색 불가) |
| `code` | 텍스트 그대로 | readonly | `<input type="text">` (완전 일치) |
**핵심:** 검색에서 date→범위, select→다중 선택으로 바뀌는 것은 **FieldType의 암묵적 속성이 아니라 SearchConfig의 렌더링 규칙**이다. 이 매핑 테이블이 spec의 일부이며, 렌더러 구현체는 이 테이블을 정확히 따라야 한다.
향후 이 매핑을 오버라이드하고 싶으면(예: 날짜를 범위 대신 단일로 검색), SearchConfig에 필드별 오버라이드를 추가한다:
```typescript
interface SearchConfig {
// ... 기존 속성
fieldOverrides?: {
[column: string]: {
searchMode?: 'exact' | 'partial' | 'range' | 'multi'; // 검색 모드 강제
searchWidget?: 'input' | 'datepicker' | 'daterange' | 'select' | 'multiselect'; // 위젯 강제
};
};
}
```
### 2.3 computed 실행 모델 (★ 보안 필수)
**`computed` 필드는 절대로 `eval()` 이나 `new Function()`으로 실행하지 않는다.**
```
❌ 금지: new Function(...keys, `return ${computed}`)
❌ 금지: eval(computed)
```
computed는 **선언적 수식 문자열**이다. 실행은 **안전한 수식 파서**만 허용한다.
**허용되는 computed 문법:**
```
// 사칙연산 + 필드 참조
'quantity * unit_price'
'order_qty - received_qty'
'(unit_price * quantity) * (1 - discount_rate)'
// 집계 함수 (detail 테이블 참조)
'SUM(order_items.amount)'
'COUNT(order_items)'
```
**허용 안 되는 것:**
```
// 함수 호출
'fetch("/api/something")'
'console.log("hack")'
'document.cookie'
// 할당/부수효과
'quantity = 0'
'window.location = "..."'
```
**구현 방식:** AST 기반 수식 파서 또는 화이트리스트 토큰 파서.
- 허용 토큰: 숫자, 필드명, +, -, *, /, (, ), SUM, COUNT, AVG, MIN, MAX
- 그 외 전부 거부
- 파서는 별도 유틸(`lib/formula-parser.ts`)로 분리
### 2.4 검증 규칙 (★ required의 정확한 의미)
**`required: true`는 "이 필드에 유효한 값이 있어야 한다"는 뜻이다.**
| 값 | required 통과? | 이유 |
|---|---|---|
| `'abc'` | ✅ | 유효한 문자열 |
| `0` | **✅** | 유효한 숫자 (수량 0은 의미 있음) |
| `false` | **✅** | 유효한 boolean |
| `''` (빈 문자열) | ❌ | 값 없음 |
| `null` | ❌ | 값 없음 |
| `undefined` | ❌ | 값 없음 |
**검증 로직 (정확한 구현):**
```typescript
function isFieldEmpty(value: any): boolean {
return value === null || value === undefined || value === '';
}
// required 검증
if (field.required && isFieldEmpty(row[field.column])) {
errors.push({ column: field.column, message: `${field.label}은(는) 필수입니다` });
}
```
**`0`, `false`, `[]`(빈 배열)은 empty가 아니다.** 이건 JavaScript의 falsy 함정과 다른 비즈니스 규칙이다.
향후 추가될 검증 규칙 (Phase 2):
```typescript
interface ValidationRule {
type: 'required' | 'min' | 'max' | 'minLength' | 'maxLength' | 'pattern' | 'custom';
value?: any; // min: 0, max: 999999, pattern: '^[A-Z]'
message?: string; // 커스텀 에러 메시지
}
// FieldConfig 확장 (Phase 2)
interface FieldConfig {
// ... 기존 속성
validations?: ValidationRule[]; // Phase 2에서 추가
}
```
---
## 3. Component — 유일한 컴포넌트 규격
```typescript
/**
* 모든 컴포넌트의 기본 구조.
* 타입이 뭐든 이 구조를 따른다.
*/
interface Component {
// ─── 식별 ───
id: string; // 고유 ID
type: ComponentType; // 컴포넌트 종류
label: string; // 빌더에서 보이는 이름
// ─── 위치 (빌더가 관리) ───
position: {
x: number;
y: number;
w: number;
h: number;
};
// ─── 반응형 (lg/md/sm 브레이크포인트) ───
responsive?: {
lg?: { x?: number; y?: number; w?: number; h?: number; }; // ≥1200px
md?: { x?: number; y?: number; w?: number; h?: number; }; // ≥768px
sm?: { x?: number; y?: number; w?: number; h?: number; }; // <768px
};
// ─── 데이터 바인딩 ───
dataSource?: {
table: string; // 바인딩 테이블
mode: 'read' | 'write' | 'readwrite';
};
fields?: FieldConfig[]; // ★ 유일한 필드 규격
// ─── 데이터 포트 (컴포넌트 간 통신) ───
inputs?: DataPort[]; // 받는 데이터
outputs?: DataPort[]; // 내보내는 데이터
// ─── 타입별 설정 (간결하게) ───
config: ComponentTypeConfig;
}
type ComponentType =
| 'table' // 데이터 테이블 (목록)
| 'form' // 입력 폼
| 'search' // 검색 필터
| 'button' // 버튼 (단일)
| 'button-bar' // 버튼 그룹
| 'tabs' // 탭
| 'split-panel' // 분할 패널
| 'title' // 텍스트/제목
| 'stats' // 통계 카드
| 'divider' // 구분선
| 'pagination'; // 페이지네이션
```
---
## 4. DataPort — 컴포넌트 간 통신 규격
```typescript
/**
* 컴포넌트가 데이터를 주고받는 포트.
* 연결은 output.name → input.name 매칭.
*/
interface DataPort {
name: string; // 포트 이름
type: DataPortType; // 데이터 형태
connectedTo?: string; // 연결된 컴포넌트 ID (빌더에서 설정)
}
type DataPortType =
| 'row' // 단일 행: Record<string, any>
| 'rows' // 복수 행: Record<string, any>[]
| 'value' // 단일 값: any
| 'params'; // 검색 파라미터: Record<string, any>
```
### 4.1 타입별 기본 포트
| 컴포넌트 | 기본 outputs | 기본 inputs |
|---|---|---|
| `table` | `selectedRow(row)`, `selectedRows(rows)` | `searchParams(params)`, `refreshTrigger(value)` |
| `form` | `formData(row)`, `savedRow(row)` | `loadRow(row)` |
| `search` | `searchParams(params)` | — |
| `button` | `clicked(value)` | — |
| `pagination` | `pageChange(params)` | `totalCount(value)` |
| `stats` | — | `data(rows)` |
### 4.2 연결 예시
```
[검색 필터] [데이터 테이블] [입력 폼]
outputs: inputs: inputs:
searchParams ──────────→ searchParams loadRow
outputs: outputs:
selectedRow ──────────→ loadRow
formData
```
빌더에서 연결을 시각적으로 설정. 실행 시 이벤트 버스로 자동 전달.
---
## 5. ComponentTypeConfig — 타입별 설정 (간결하게)
### 5.1 table
```typescript
interface TableConfig {
pageSize: number; // 페이지 크기 (기본: 20)
selectionMode: 'none' | 'single' | 'multiple';
showCheckbox: boolean;
inlineEdit: boolean; // 인라인 편집
autoLoad: boolean; // 자동 로드
defaultSort?: { column: string; direction: 'asc' | 'desc'; };
toolbar: {
showExcel: boolean;
showRefresh: boolean;
showFilter: boolean;
};
style: 'default' | 'striped' | 'bordered' | 'compact';
}
```
### 5.2 form
```typescript
interface FormConfig {
columns: 1 | 2 | 3; // 폼 컬럼 수
sections?: { // 섹션 구분 (선택)
label: string;
fields: string[]; // FieldConfig.column 참조
}[];
saveAction: {
method: 'INSERT' | 'UPDATE' | 'UPSERT';
successMessage?: string;
refreshAfterSave: boolean;
};
}
```
### 5.3 search
```typescript
interface SearchConfig {
dateRangeEnabled: boolean; // 날짜 범위 검색
showResetButton: boolean; // 초기화 버튼
autoSearch: boolean; // 입력 시 자동 검색
layout: 'inline' | 'stacked'; // 검색 필드 배치
}
```
### 5.4 button / button-bar
```typescript
interface ButtonConfig {
text: string;
actionType: ActionType; // 12종
variant: 'primary' | 'default' | 'destructive' | 'outline' | 'ghost';
confirm?: string; // 확인 메시지 (있으면 확인 팝업)
flow?: { // 제어 플로우 연결
flowId: string;
timing: 'before' | 'after';
};
}
type ActionType =
'save' | 'edit' | 'delete' | 'add' | 'cancel' | 'close' |
'navigate' | 'popup' | 'search' | 'reset' | 'submit' | 'approval';
interface ButtonBarConfig {
buttons: ButtonConfig[];
}
```
### 5.5 tabs
```typescript
interface TabsConfig {
tabs: { label: string; id: string; }[];
defaultTab: string; // 기본 선택 탭 ID
}
```
### 5.6 기타 (title, stats, divider, pagination)
```typescript
interface TitleConfig {
text: string;
fontSize: string;
fontWeight: string;
align: 'left' | 'center' | 'right';
}
interface StatsConfig {
items: { label: string; column: string; aggregation: 'count' | 'sum' | 'avg'; }[];
}
interface DividerConfig {
style: 'solid' | 'dashed' | 'dotted';
}
interface PaginationConfig {
pageSize: number;
showSizeSelector: boolean;
sizeOptions: number[];
}
```
---
## 6. Template — 화면 단위
```typescript
/**
* 한 화면 = 한 템플릿.
* 목록 + 등록팝업 + 수정팝업이 한 덩어리.
*/
interface Template {
// ─── 식별 ───
templateId: string;
name: string;
category: string; // sales, production, purchase, ...
description?: string;
// ─── 데이터 ───
primaryTable: string; // 메인 테이블
fields: FieldConfig[]; // ★ 이 필드 목록을 모든 뷰가 공유
// ─── 3뷰 ───
views: {
list: ViewConfig; // 목록 화면
create: ViewConfig; // 등록 팝업
edit: ViewConfig; // 수정 팝업 (create 상속 가능)
};
// ─── 연결 ───
connections: Connection[]; // 컴포넌트 간 DataPort 연결
// ─── 메타 ───
companyCode: string;
version: number;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
interface ViewConfig {
components: Component[]; // 이 뷰에 배치된 컴포넌트들
extends?: 'create'; // edit가 create를 상속할 때
size?: { w: number; h: number; }; // 팝업 크기 (create/edit용)
}
interface Connection {
id: string;
from: { componentId: string; port: string; };
to: { componentId: string; port: string; };
}
```
---
## 7. vex 대비 비교
| | vex | invyone |
|---|---|---|
| 필드 규격 | ColumnConfig(354줄) + FilterConfig + FormField = 전부 다름 | **FieldConfig 하나 (~30줄)** |
| 컴포넌트 간 통신 | 자체 구현, 컴포넌트마다 다름 | **DataPort 표준 프로토콜** |
| 컴포넌트 타입 | 33종 (v2-*), 각각 다른 규격 | **11종, 공통 base + 간결한 TypeConfig** |
| 테이블 설정 | TableListConfig 354줄 | **TableConfig ~15줄** |
| 화면 구조 | 목록/등록/수정 = 3개 화면 따로 | **Template 1개에 3뷰 내장** |
| 필드 공유 | 화면마다 따로 정의 | **Template.fields를 모든 뷰가 공유** |
| 데이터 전달 | 컴포넌트별 커스텀 이벤트 | **output → input 자동 매칭** |
---
## 8. 확장 방법
새 필드 타입 추가:
```
1. FieldType에 새 타입 추가 (예: 'color')
2. 각 렌더러(table/form/search)에 해당 타입 렌더링 추가
→ 끝. FieldConfig 구조는 변경 없음.
```
새 컴포넌트 타입 추가:
```
1. ComponentType에 새 타입 추가 (예: 'chart')
2. 해당 TypeConfig 정의 (예: ChartConfig)
3. DataPort 기본 포트 정의
4. 렌더러 구현
→ 끝. 기존 컴포넌트/필드 규격에 영향 없음.
```
새 액션 타입 추가:
```
1. ActionType에 새 타입 추가 (예: 'email')
2. 액션 핸들러 구현
→ 끝.
```
---
## 9. 데이터 흐름 (전체 그림)
```
[table_type_columns DB]
↓ (자동 로드)
[Template.fields: FieldConfig[]] ← 유일한 진실의 원천
↓ (공유)
┌────┼────┐
↓ ↓ ↓
목록 등록 수정 ← 3뷰가 같은 fields 공유
│ │ │
↓ ↓ ↓
[Component[]] ← 각 뷰의 컴포넌트 배열
[DataPort 연결] ← 검색→테이블→폼 자동 흐름
[렌더러] ← FieldConfig.type 보고 적절한 UI
```
---
## 10. 다음 단계
1. FieldConfig 타입스크립트 정의 확정
2. 빌더 mockup에 이 규격 적용
3. 렌더러 프로토타입 (FieldConfig → React 컴포넌트)
4. DataPort 이벤트 버스 구현
File diff suppressed because it is too large Load Diff
+329 -72
View File
@@ -22,10 +22,55 @@
<link rel="stylesheet" href="css/07-control-mode.css">
<link rel="stylesheet" href="css/08-rule-builder.css">
<style>
/* ═══ 어드민 빌더 (index 내장) ═══ */
/* ═══ 어드민 빌더 v2 (index 내장) ═══ */
.admin-builder{display:flex;flex-direction:column;position:absolute;inset:0;z-index:5;background:var(--bg);}
.dark .admin-builder{background:#121218;}
/* 뷰 탭 */
.ab-view-tabs{display:flex;align-items:center;padding:0 .6rem;height:30px;
background:var(--bg);border-bottom:1px solid;border-color:rgba(108,92,231,.08);}
.dark .ab-view-tabs{background:#1a1a22;border-color:#3a3a48;}
html:not(.dark) .ab-view-tabs{background:#ededf2;border-color:#d0d0dc;}
.ab-view-tab{padding:.3rem .7rem;font-size:.52rem;font-weight:600;color:var(--text-muted);cursor:pointer;
border-bottom:2px solid transparent;transition:all .12s;margin-bottom:-1px;}
.ab-view-tab:hover{color:var(--text);}
.ab-view-tab.active{color:var(--primary);border-bottom-color:var(--primary);}
.dark .ab-view-tab.active{color:#5b9ef5;border-bottom-color:#5b9ef5;}
.ab-vt-icon{margin-right:.2rem;}
.ab-view-hint{font-size:.42rem;color:var(--text-muted);margin-right:.4rem;}
/* 캔버스 래퍼 (팝업 오버레이 컨테이너) */
.ab-canvas-wrap{flex:1;overflow:auto;position:relative;
background:var(--bg,#f0f0f5);}
.dark .ab-canvas-wrap{background:#0e0e14;}
/* 팝업 오버레이 */
.ab-popup-overlay{position:absolute;inset:0;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);
display:none;align-items:center;justify-content:center;z-index:20;}
.ab-popup-overlay.show{display:flex;}
.ab-popup-frame{background:var(--bg);border:2px dashed var(--primary);border-radius:10px;
min-width:460px;max-width:640px;width:55%;max-height:80%;overflow-y:auto;position:relative;padding:.8rem;}
.dark .ab-popup-frame{background:#1a1a22;border-color:#5b9ef5;}
html:not(.dark) .ab-popup-frame{background:#fff;border-color:var(--primary);}
.ab-popup-label{position:absolute;top:-1px;left:10px;padding:0 .3rem;font-size:.42rem;
font-weight:700;color:var(--primary);background:var(--bg);}
.dark .ab-popup-label{color:#5b9ef5;background:#1a1a22;}
/* 리사이즈 핸들 */
.ab-resize-handle{position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:nwse-resize;z-index:5;}
.ab-resize-handle::after{content:'';position:absolute;right:2px;bottom:2px;width:6px;height:6px;
border-right:2px solid var(--primary,#5b9ef5);border-bottom:2px solid var(--primary,#5b9ef5);opacity:.5;}
.ab-block:hover .ab-resize-handle::after,.ab-block.selected .ab-resize-handle::after{opacity:1;}
/* 드래그 중 선택 방지 */
.ab-canvas.dragging,.ab-canvas.dragging *{user-select:none !important;}
/* 상태바 */
.ab-statusbar{display:flex;align-items:center;justify-content:space-between;
padding:0 .6rem;height:20px;font-size:.42rem;color:var(--text-muted);
border-top:1px solid;border-color:rgba(108,92,231,.08);}
.dark .ab-statusbar{background:#1a1a22;border-color:#3a3a48;}
html:not(.dark) .ab-statusbar{background:#ededf2;border-color:#d0d0dc;}
.ab-toolbar{display:flex;align-items:center;gap:.4rem;padding:0 .6rem;height:34px;
border-bottom:1px solid;border-color:rgba(108,92,231,.1);}
.dark .ab-toolbar{background:#1a1a22;border-color:#3a3a48;}
@@ -61,12 +106,13 @@ html:not(.dark) .ab-pal-sec{color:var(--primary);}
.dark .ab-pal-item:hover{background:#2a2a36;color:#e8e8ee;}
.ab-pi{width:16px;text-align:center;font-size:.6rem;flex-shrink:0;}
/* 캔버스 */
.ab-canvas{flex:1;overflow:auto;position:relative;}
.dark .ab-canvas{background:#121218;
background-image:radial-gradient(circle,rgba(255,255,255,.03) .5px,transparent .5px);background-size:20px 20px;}
html:not(.dark) .ab-canvas{
background-image:radial-gradient(circle,rgba(0,0,0,.06) .5px,transparent .5px);background-size:20px 20px;}
/* 캔버스 — 실제 화면 프리뷰 느낌 */
.ab-canvas{position:relative;min-width:900px;min-height:600px;padding:12px;
border-radius:8px;margin:12px;}
.dark .ab-canvas{background:#1a1a22;
box-shadow:0 2px 20px rgba(0,0,0,.3),inset 0 0 0 1px rgba(255,255,255,.03);}
html:not(.dark) .ab-canvas{background:#f0f0f5;
box-shadow:0 2px 20px rgba(0,0,0,.08),inset 0 0 0 1px rgba(0,0,0,.04);}
.ab-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--text-muted);}
/* ═══ 라이트모드 빌더 강화 ═══ */
@@ -74,15 +120,15 @@ html:not(.dark) .admin-builder{background:#f0f0f5;}
html:not(.dark) .ab-toolbar{background:#e8e8f0;border-bottom:1px solid #d0d0dc;}
html:not(.dark) .ab-palette{background:#f5f5fa;border-right:1px solid #d0d0dc;}
html:not(.dark) .ab-props{background:#f5f5fa;border-left:1px solid #d0d0dc;}
html:not(.dark) .ab-canvas{background:#e4e4ec;
background-image:radial-gradient(circle,rgba(0,0,0,.08) .5px,transparent .5px);background-size:20px 20px;}
html:not(.dark) .ab-canvas{background:#f0f0f5;
box-shadow:0 2px 20px rgba(0,0,0,.08),inset 0 0 0 1px rgba(0,0,0,.04);}
html:not(.dark) .ab-props-header{background:#ececf4;border-bottom:1px solid #d0d0dc;color:#1a1a24;}
html:not(.dark) .ab-pal-sec{color:#3b7dd8;font-weight:800;}
html:not(.dark) .ab-pal-item{color:#4a4a5e;}
html:not(.dark) .ab-pal-item:hover{background:#e8e8f2;color:#1a1a24;}
html:not(.dark) .ab-block{background:#fff;border-color:#c4c4d4;}
html:not(.dark) .ab-block:hover{border-color:#6c5ce7;}
html:not(.dark) .ab-block.selected{border-color:#6c5ce7;box-shadow:0 0 0 3px rgba(108,92,231,.15);}
html:not(.dark) .ab-block{background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);}
html:not(.dark) .ab-block:hover{border-color:rgba(108,92,231,.35);}
html:not(.dark) .ab-block.selected{border-color:#6c5ce7;box-shadow:0 0 0 3px rgba(108,92,231,.12);}
html:not(.dark) .ab-block-label{background:#e4e4ec;color:#6c5ce7;}
html:not(.dark) .ab-block.selected .ab-block-label{background:#6c5ce7;color:#fff;}
html:not(.dark) .ab-pv-tbl th{color:#5a5a6e;border-color:#d8d8e2;}
@@ -125,15 +171,23 @@ html:not(.dark) .ab-popup-block.selected{border-color:#6c5ce7;}
.ab-props-empty{padding:1.5rem .5rem;text-align:center;font-size:.52rem;color:var(--text-muted);line-height:1.5;}
/* 블록 */
.ab-block{position:absolute;border:1.5px dashed var(--border);border-radius:6px;
background:var(--surface);cursor:pointer;transition:border-color .1s;overflow:hidden;}
.dark .ab-block{background:#1e1e28;border-color:#3a3a48;}
.ab-block:hover{border-color:var(--primary);}
/* 블록 — 배경 있음, 테두리는 hover/선택 시 */
.ab-block{position:absolute;border:1.5px solid transparent;border-radius:6px;
background:var(--surface,#fff);cursor:pointer;transition:border-color .15s,box-shadow .15s;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.06);}
.dark .ab-block{background:#1e1e28;box-shadow:0 1px 3px rgba(0,0,0,.2);}
/* hover → 점선 테두리 */
.ab-block:hover{border-color:rgba(108,92,231,.4);border-style:dashed;}
.dark .ab-block:hover{border-color:rgba(91,158,245,.4);}
/* selected → 실선 + 글로우 */
.ab-block.selected{border-color:var(--primary);border-style:solid;border-width:2px;
box-shadow:0 0 0 3px rgba(108,92,231,.12);}
.dark .ab-block.selected{box-shadow:0 0 0 3px rgba(91,158,245,.12);}
/* 라벨 — hover/선택 시만 보임 */
.ab-block-label{position:absolute;top:-1px;left:6px;padding:0 .25rem;font-size:.38rem;
font-weight:700;color:var(--primary);background:var(--bg);z-index:1;}
font-weight:700;color:var(--primary);background:var(--bg);z-index:1;
opacity:0;transition:opacity .15s;pointer-events:none;}
.ab-block:hover .ab-block-label,.ab-block.selected .ab-block-label{opacity:1;}
.dark .ab-block-label{background:#121218;color:#5b9ef5;}
.ab-block.selected .ab-block-label{background:var(--primary);color:#fff;border-radius:0 0 3px 3px;padding:.02rem .25rem;}
.dark .ab-block.selected .ab-block-label{background:#5b9ef5;}
@@ -292,20 +346,40 @@ html:not(.dark) .ab-map-dialog{background:#fff;}
.ab-map-save:hover{opacity:.85;}
.ab-map-cancel:hover{border-color:var(--text-muted);}
/* ═══ 팝업 오버레이 (빌더 내) ═══ */
.ab-popup-overlay{position:absolute;inset:0;z-index:20;
background:rgba(0,0,0,.35);backdrop-filter:blur(2px);
display:flex;align-items:center;justify-content:center;
animation:abPopFadeIn .2s ease;}
@keyframes abPopFadeIn{from{opacity:0;}to{opacity:1;}}
/* ═══ FieldConfig 상세 패널 ═══ */
.ab-fc-detail{width:100%;padding:.25rem .1rem .15rem;background:rgba(91,158,245,.03);
border:1px dashed rgba(91,158,245,.15);border-radius:4px;margin-top:.1rem;}
.dark .ab-fc-detail{background:rgba(91,158,245,.06);border-color:rgba(91,158,245,.12);}
html:not(.dark) .ab-fc-detail{background:rgba(108,92,231,.03);border-color:rgba(108,92,231,.12);}
.ab-fc-row{display:flex;align-items:center;gap:.2rem;padding:.08rem .2rem;}
.ab-fc-label{font-size:.36rem;font-weight:700;color:var(--text-muted);min-width:52px;text-align:right;font-family:monospace;}
.dark .ab-fc-label{color:#8a8a9e;}
html:not(.dark) .ab-fc-label{color:#6a6a7e;}
.ab-fc-val{flex:1;padding:.1rem .2rem;border-radius:3px;border:1px solid var(--border);
background:var(--surface);font-size:.42rem;color:var(--text);outline:none;box-sizing:border-box;min-width:0;}
.dark .ab-fc-val{background:#22222c;border-color:#3a3a48;color:#e8e8ee;}
html:not(.dark) .ab-fc-val{background:#fff;border-color:#d0d0dc;color:#1a1a24;}
.ab-fc-val:focus{border-color:var(--primary);}
.ab-fc-toggles{display:flex;flex-wrap:wrap;gap:.15rem .3rem;padding:.1rem .2rem;}
.ab-fc-tog{display:flex;align-items:center;gap:.15rem;cursor:pointer;}
.ab-fc-tog-label{font-size:.36rem;font-weight:600;color:var(--text-muted);}
/* FieldConfig 배지 */
.ab-fc-badge{font-size:.3rem;font-weight:700;padding:.02rem .15rem;border-radius:2px;line-height:1.3;}
.ab-fc-badge.pk{background:rgba(251,146,60,.15);color:#fb923c;}
.ab-fc-badge.req{background:rgba(248,113,113,.15);color:#f87171;}
.ab-fc-badge.sch{background:rgba(34,211,238,.15);color:#22d3ee;}
.ab-fc-badge.sys{background:rgba(148,163,184,.15);color:#94a3b8;}
.ab-fc-badge.cmp{background:rgba(167,139,250,.15);color:#a78bfa;}
html:not(.dark) .ab-fc-badge.pk{background:rgba(251,146,60,.1);color:#ea580c;}
html:not(.dark) .ab-fc-badge.req{background:rgba(248,113,113,.1);color:#dc2626;}
html:not(.dark) .ab-fc-badge.sch{background:rgba(6,182,212,.1);color:#0891b2;}
html:not(.dark) .ab-fc-badge.sys{background:rgba(100,116,139,.1);color:#64748b;}
html:not(.dark) .ab-fc-badge.cmp{background:rgba(139,92,246,.1);color:#7c3aed;}
/* 확장된 필드 행 하이라이트 */
.ab-p-field-expanded{background:rgba(91,158,245,.05);border-radius:4px;padding:.14rem .08rem !important;}
html:not(.dark) .ab-p-field-expanded{background:rgba(108,92,231,.04);}
.ab-popup-dialog{width:580px;max-height:85%;overflow-y:auto;
border-radius:10px;border:2px solid var(--primary);
animation:abPopSlide .25s cubic-bezier(.16,1,.3,1);}
.dark .ab-popup-dialog{background:#1a1a22;border-color:#5b9ef5;
box-shadow:0 20px 60px rgba(0,0,0,.5),0 0 30px rgba(91,158,245,.1);}
html:not(.dark) .ab-popup-dialog{background:#fff;box-shadow:0 20px 60px rgba(0,0,0,.15);}
@keyframes abPopSlide{from{transform:translateY(20px);opacity:0;}to{transform:translateY(0);opacity:1;}}
/* ═══ 팝업 오버레이 (빌더 내) — v2에서 .ab-popup-overlay로 통합, 여기는 비움 ═══ */
.ab-popup-header{display:flex;align-items:flex-start;gap:.4rem;padding:.7rem .8rem;
border-bottom:1px solid var(--border);position:relative;}
@@ -337,6 +411,156 @@ html:not(.dark) .ab-popup-dialog{background:#fff;box-shadow:0 20px 60px rgba(0,0
transform:translateX(-50%);font-size:.35rem;color:var(--cyan);white-space:nowrap;
opacity:0;transition:opacity .15s;pointer-events:none;}
.ab-action-btn:hover::after{opacity:1;}
/* ═══ 테이블 설정 모달 ═══ */
.ab-tbl-modal-backdrop{position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.45);backdrop-filter:blur(3px);display:none;}
.ab-tbl-modal-backdrop.show{display:block;}
.ab-tbl-modal{position:fixed;z-index:9999;top:50%;left:50%;transform:translate(-50%,-50%);
width:680px;max-width:92vw;max-height:85vh;display:none;flex-direction:column;
border-radius:12px;border:1px solid var(--border);overflow:hidden;
box-shadow:0 24px 80px rgba(0,0,0,.25);}
.ab-tbl-modal.show{display:flex;}
.dark .ab-tbl-modal{background:#1a1a22;border-color:#3a3a48;}
html:not(.dark) .ab-tbl-modal{background:#fff;border-color:#d0d0dc;}
.ab-tbl-modal-header{padding:.7rem 1rem .5rem;border-bottom:1px solid var(--border);}
.dark .ab-tbl-modal-header{border-color:#3a3a48;}
html:not(.dark) .ab-tbl-modal-header{border-color:#e0e0ec;}
.ab-tbl-modal-title{font-size:.8rem;font-weight:800;color:var(--text);display:flex;align-items:center;gap:.3rem;}
.ab-tbl-modal-subtitle{font-size:.46rem;color:var(--text-muted);margin-top:.15rem;}
.ab-tbl-modal-close{position:absolute;top:.5rem;right:.7rem;width:26px;height:26px;border-radius:6px;
border:1px solid var(--border);background:transparent;color:var(--text-muted);font-size:.7rem;
cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .1s;}
.ab-tbl-modal-close:hover{background:rgba(255,71,87,.1);border-color:rgba(255,71,87,.3);color:#f87171;}
/* 탭 */
.ab-tbl-modal-tabs{display:flex;border-bottom:1px solid var(--border);padding:0 1rem;}
.dark .ab-tbl-modal-tabs{border-color:#3a3a48;}
html:not(.dark) .ab-tbl-modal-tabs{border-color:#e0e0ec;}
.ab-tbl-modal-tab{padding:.4rem .8rem;font-size:.52rem;font-weight:600;color:var(--text-muted);
cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .12s;}
.ab-tbl-modal-tab:hover{color:var(--text);}
.ab-tbl-modal-tab.active{color:var(--primary);border-bottom-color:var(--primary);}
.dark .ab-tbl-modal-tab.active{color:#5b9ef5;border-bottom-color:#5b9ef5;}
html:not(.dark) .ab-tbl-modal-tab.active{color:#6c5ce7;border-bottom-color:#6c5ce7;}
/* 본문 */
.ab-tbl-modal-body{flex:1;overflow-y:auto;padding:.6rem 1rem;min-height:200px;max-height:calc(85vh - 180px);}
/* 카운터 */
.ab-tbl-counter{display:flex;align-items:center;gap:.5rem;padding:.3rem .5rem;margin-bottom:.5rem;
border-radius:6px;font-size:.46rem;font-weight:600;}
.dark .ab-tbl-counter{background:rgba(91,158,245,.06);color:#8a8a9e;}
html:not(.dark) .ab-tbl-counter{background:rgba(108,92,231,.04);color:#5a5a6e;}
.ab-tbl-counter-num{font-weight:800;color:var(--primary);}
.dark .ab-tbl-counter-num{color:#5b9ef5;}
html:not(.dark) .ab-tbl-counter-num{color:#6c5ce7;}
/* 컬럼 행 */
.ab-tbl-col-row{display:flex;align-items:center;gap:.4rem;padding:.35rem .5rem;
border-bottom:1px solid var(--border-subtle,rgba(0,0,0,.06));border-radius:4px;
transition:background .1s;cursor:default;}
.ab-tbl-col-row:hover{background:rgba(91,158,245,.04);}
html:not(.dark) .ab-tbl-col-row:hover{background:rgba(108,92,231,.03);}
.dark .ab-tbl-col-row{border-color:#2a2a36;}
html:not(.dark) .ab-tbl-col-row{border-color:#e8e8f0;}
/* 드래그 핸들 */
.ab-tbl-drag-handle{cursor:grab;color:var(--text-muted);font-size:.55rem;opacity:.5;user-select:none;flex-shrink:0;width:14px;text-align:center;}
.ab-tbl-col-row:hover .ab-tbl-drag-handle{opacity:1;}
.ab-tbl-col-row.dragging{opacity:.4;background:rgba(91,158,245,.08);}
.ab-tbl-col-row.drag-over{border-top:2px solid var(--primary);}
.dark .ab-tbl-col-row.drag-over{border-top-color:#5b9ef5;}
/* VEX 토글 (초록색) */
.ab-tbl-toggle{width:32px;height:17px;border-radius:10px;position:relative;cursor:pointer;
transition:background .15s;flex-shrink:0;}
.ab-tbl-toggle.off{background:#ccc;}
.dark .ab-tbl-toggle.off{background:#3a3a48;}
.ab-tbl-toggle.on{background:#22c55e;}
.ab-tbl-toggle::after{content:'';position:absolute;width:13px;height:13px;border-radius:50%;
background:#fff;top:2px;left:2px;transition:left .15s;box-shadow:0 1px 3px rgba(0,0,0,.15);}
.ab-tbl-toggle.on::after{left:17px;}
/* 라벨 영역 */
.ab-tbl-col-label{font-size:.5rem;font-weight:600;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.dark .ab-tbl-col-label{color:#e8e8ee;}
html:not(.dark) .ab-tbl-col-label{color:#1a1a24;}
.ab-tbl-col-db{font-size:.38rem;color:var(--text-muted);font-family:monospace;flex-shrink:0;}
/* 너비 설정 */
.ab-tbl-col-width{display:flex;align-items:center;gap:.2rem;flex-shrink:0;}
.ab-tbl-col-width select,.ab-tbl-col-width input{padding:.12rem .25rem;border-radius:4px;border:1px solid var(--border);
background:var(--surface);font-size:.42rem;color:var(--text);outline:none;}
.dark .ab-tbl-col-width select,.dark .ab-tbl-col-width input{background:#22222c;border-color:#3a3a48;color:#e8e8ee;}
html:not(.dark) .ab-tbl-col-width select,html:not(.dark) .ab-tbl-col-width input{background:#fff;border-color:#d0d0dc;color:#1a1a24;}
.ab-tbl-col-width input{width:50px;text-align:center;}
/* 필터 탭 체크박스 */
.ab-tbl-checkbox{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--border);
display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;
transition:all .1s;font-size:.45rem;color:transparent;}
.ab-tbl-checkbox.on{background:#22c55e;border-color:#22c55e;color:#fff;}
html:not(.dark) .ab-tbl-checkbox.on{background:#22c55e;border-color:#22c55e;}
.dark .ab-tbl-checkbox{border-color:#3a3a48;}
html:not(.dark) .ab-tbl-checkbox{border-color:#d0d0dc;}
/* 필터 타입 드롭다운 */
.ab-tbl-filter-type{padding:.15rem .3rem;border-radius:4px;border:1px solid var(--border);
background:var(--surface);font-size:.42rem;color:var(--text);outline:none;flex-shrink:0;}
.dark .ab-tbl-filter-type{background:#22222c;border-color:#3a3a48;color:#e8e8ee;}
html:not(.dark) .ab-tbl-filter-type{background:#fff;border-color:#d0d0dc;color:#1a1a24;}
/* 기본 데이터 필터 */
.ab-tbl-default-filter{margin-top:.6rem;padding:.5rem;border-radius:6px;
border:1px dashed var(--border);}
.dark .ab-tbl-default-filter{background:rgba(91,158,245,.03);border-color:#3a3a48;}
html:not(.dark) .ab-tbl-default-filter{background:rgba(108,92,231,.02);border-color:#d0d0dc;}
.ab-tbl-df-title{font-size:.46rem;font-weight:700;color:var(--text-muted);margin-bottom:.3rem;}
.ab-tbl-df-row{display:flex;align-items:center;gap:.3rem;margin-bottom:.25rem;}
.ab-tbl-df-row select,.ab-tbl-df-row input{padding:.15rem .3rem;border-radius:4px;
border:1px solid var(--border);background:var(--surface);font-size:.42rem;color:var(--text);outline:none;flex:1;}
.dark .ab-tbl-df-row select,.dark .ab-tbl-df-row input{background:#22222c;border-color:#3a3a48;color:#e8e8ee;}
html:not(.dark) .ab-tbl-df-row select,html:not(.dark) .ab-tbl-df-row input{background:#fff;border-color:#d0d0dc;color:#1a1a24;}
.ab-tbl-df-add{width:100%;padding:.2rem;border-radius:4px;border:1px dashed var(--border);
background:transparent;color:var(--text-muted);font-size:.42rem;cursor:pointer;text-align:center;transition:all .1s;}
.ab-tbl-df-add:hover{border-color:var(--primary);color:var(--primary);}
/* 그룹 소계 토글 */
.ab-tbl-subtotal-row{display:flex;align-items:center;justify-content:space-between;
padding:.4rem .5rem;margin-top:.4rem;border-radius:6px;border:1px solid var(--border);}
.dark .ab-tbl-subtotal-row{background:rgba(91,158,245,.04);border-color:#3a3a48;}
html:not(.dark) .ab-tbl-subtotal-row{background:rgba(108,92,231,.03);border-color:#d0d0dc;}
.ab-tbl-subtotal-label{font-size:.48rem;font-weight:600;color:var(--text);}
/* 푸터 */
.ab-tbl-modal-footer{display:flex;align-items:center;justify-content:flex-end;gap:.4rem;
padding:.5rem 1rem;border-top:1px solid var(--border);}
.dark .ab-tbl-modal-footer{border-color:#3a3a48;}
html:not(.dark) .ab-tbl-modal-footer{border-color:#e0e0ec;}
.ab-tbl-modal-btn{padding:.3rem .8rem;border-radius:6px;border:1px solid var(--border);
background:var(--surface);color:var(--text);font-size:.52rem;font-weight:600;cursor:pointer;transition:all .1s;}
.ab-tbl-modal-btn:hover{border-color:var(--text-muted);}
.dark .ab-tbl-modal-btn{background:#22222c;border-color:#3a3a48;color:#e8e8ee;}
html:not(.dark) .ab-tbl-modal-btn{background:#fff;border-color:#d0d0dc;color:#3a3a4e;}
.ab-tbl-modal-btn.primary{background:var(--primary);border-color:var(--primary);color:#fff;}
.dark .ab-tbl-modal-btn.primary{background:#5b9ef5;border-color:#5b9ef5;}
html:not(.dark) .ab-tbl-modal-btn.primary{background:#6c5ce7;border-color:#6c5ce7;}
.ab-tbl-modal-btn.primary:hover{opacity:.85;}
/* 속성 패널 테이블 설정 버튼 */
.ab-p-tbl-settings-btn{display:flex;align-items:center;justify-content:center;gap:.25rem;
width:calc(100% - 1.1rem);margin:.3rem .55rem;padding:.3rem;border-radius:6px;
border:1px solid var(--primary);background:rgba(108,92,231,.06);color:var(--primary);
font-size:.48rem;font-weight:700;cursor:pointer;transition:all .12s;}
.ab-p-tbl-settings-btn:hover{background:rgba(108,92,231,.12);}
.dark .ab-p-tbl-settings-btn{border-color:#5b9ef5;color:#5b9ef5;background:rgba(91,158,245,.06);}
.dark .ab-p-tbl-settings-btn:hover{background:rgba(91,158,245,.12);}
html:not(.dark) .ab-p-tbl-settings-btn{border-color:#6c5ce7;color:#6c5ce7;background:rgba(108,92,231,.06);}
/* 패널 탭 */
.ab-tbl-modal-pane{display:none;}
.ab-tbl-modal-pane.active{display:block;}
</style>
</head>
<body>
@@ -856,9 +1080,18 @@ html:not(.dark) .ab-popup-dialog{background:#fff;box-shadow:0 20px 60px rgba(0,0
</div>
<!-- ═══ 개발자 모드: 템플릿 빌더 (어드민 → 새 템플릿) ═══ -->
<!-- ═══ 개발자 모드: 템플릿 빌더 v2 ═══ -->
<div class="admin-builder" id="admin-builder" style="display:none;">
<!-- 뷰 탭 (한 템플릿 3뷰) -->
<div class="ab-view-tabs">
<div class="ab-view-tab active" data-view="list" onclick="abSwitchView('list')"><span class="ab-vt-icon">📋</span>목록 화면</div>
<div class="ab-view-tab" data-view="create" onclick="abSwitchView('create')"><span class="ab-vt-icon"></span>등록 팝업</div>
<div class="ab-view-tab" data-view="edit" onclick="abSwitchView('edit')"><span class="ab-vt-icon">✏️</span>수정 팝업</div>
<div style="flex:1;"></div>
<span class="ab-view-hint" id="ab-view-hint">목록 + 검색 + 그리드 구성</span>
</div>
<!-- 빌더 도구모음 -->
<div class="ab-toolbar">
<div class="ab-tb-group">
@@ -872,61 +1105,84 @@ html:not(.dark) .ab-popup-dialog{background:#fff;box-shadow:0 20px 60px rgba(0,0
<button class="ab-tb-btn active" data-preset="basic">기본형</button>
<button class="ab-tb-btn" data-preset="split">분할형</button>
<button class="ab-tb-btn" data-preset="tabs">탭형</button>
<button class="ab-tb-btn" data-preset="md">M-D형</button>
</div>
<div style="flex:1;"></div>
<button class="ab-tb-btn gen" onclick="abGenerate()"> 자동 생성</button>
<button class="ab-tb-btn gen" onclick="abGenerate()">⚡ 생성</button>
<button class="ab-tb-btn" onclick="showAdminView('template-list')">← 목록으로</button>
</div>
<!-- 3패널 -->
<div class="ab-panels">
<!-- 좌: 컴포넌트 팔레트 -->
<!-- 팔레트: FieldConfig를 소비하는 컴포넌트만 -->
<!-- 레이아웃 = 프리셋이 처리 (내부 모델에는 존재, 팔레트에서 숨김) -->
<!-- 필드 = FieldConfig.type (컴포넌트가 아님, 팔레트에서 제거) -->
<!-- 개별 액션(저장/삭제) = ActionType (버튼 바의 속성, 팔레트에서 제거) -->
<aside class="ab-palette" id="ab-palette">
<div class="ab-pal-sec">레이아웃</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📐</span>그리드</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>분할 패널</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📑</span></div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>카드/패널</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>모달/팝업</div>
<div class="ab-pal-sec">데이터</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📋</span>데이터 테이블</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">🔍</span>검색 필터</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📝</span>입력 폼</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>마스터-디테일</div>
<div class="ab-pal-sec">입력</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">Aa</span>텍스트</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">#</span>숫자</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📅</span>날짜</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>드롭다운</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>체크박스</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📎</span>파일 업로드</div>
<div class="ab-pal-item" draggable="true" data-cat="data" data-comp="데이터 테이블"><span class="ab-pi" style="color:var(--primary,#5b9ef5);"></span>데이터 테이블</div>
<div class="ab-pal-item" draggable="true" data-cat="data" data-comp="검색 필터"><span class="ab-pi" style="color:var(--primary,#5b9ef5);">🔍</span>검색 필터</div>
<div class="ab-pal-item" draggable="true" data-cat="data" data-comp="입력 폼"><span class="ab-pi" style="color:var(--primary,#5b9ef5);"></span>입력 폼</div>
<div class="ab-pal-sec">액션</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">💾</span>저장</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">🗑</span>삭제</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>결재</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi"></span>커스텀 버튼</div>
<div class="ab-pal-item" draggable="true" data-cat="action" data-comp="버튼"><span class="ab-pi" style="color:#f472b6;"></span>버튼</div>
<div class="ab-pal-sec">표시</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📊</span>통계 카드</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">📈</span>차트</div>
<div class="ab-pal-item" draggable="true"><span class="ab-pi">T</span>텍스트/제목</div>
<div class="ab-pal-item" draggable="true" data-cat="display" data-comp="통계 카드"><span class="ab-pi" style="color:#fb923c;">📊</span>통계 카드</div>
<div class="ab-pal-item" draggable="true" data-cat="display" data-comp="차트"><span class="ab-pi" style="color:#fb923c;">📈</span>차트</div>
<div class="ab-pal-item" draggable="true" data-cat="display" data-comp="텍스트/제목"><span class="ab-pi" style="color:#fb923c;">T</span>텍스트/제목</div>
<div class="ab-pal-item" draggable="true" data-cat="display" data-comp="구분선"><span class="ab-pi" style="color:#fb923c;"></span>구분선</div>
</aside>
<!-- 중: 빌더 캔버스 -->
<div class="ab-canvas" id="ab-canvas">
<div class="ab-empty" id="ab-empty">
<div style="font-size:2rem;opacity:.3;margin-bottom:.5rem;">📐</div>
<div style="font-size:.65rem;font-weight:600;margin-bottom:.2rem;">테이블을 선택하고 프리셋을 고르세요</div>
<div style="font-size:.5rem;opacity:.6;">또는 좌측 팔레트에서 컴포넌트를 드래그하세요</div>
<div class="ab-canvas-wrap" id="ab-canvas-wrap">
<div class="ab-canvas" id="ab-canvas">
<div class="ab-empty" id="ab-empty">
<div style="font-size:2rem;opacity:.3;margin-bottom:.5rem;"></div>
<div style="font-size:.65rem;font-weight:600;margin-bottom:.2rem;">테이블을 선택하고 프리셋을 고르세요</div>
<div style="font-size:.5rem;opacity:.6;">또는 좌측 팔레트에서 컴포넌트를 드래그하세요</div>
</div>
</div>
<!-- 팝업 오버레이 (등록/수정 뷰) -->
<div class="ab-popup-overlay" id="ab-popup-overlay">
<div class="ab-popup-frame" id="ab-popup-frame"></div>
</div>
</div>
<!-- 우: 속성 패널 -->
<aside class="ab-props" id="ab-props">
<div class="ab-props-header">속성</div>
<div class="ab-props-empty">캔버스에서 컴포넌트를<br>선택하세요</div>
<div id="ab-props-content" style="padding:.6rem;text-align:center;color:var(--text-muted);font-size:.5rem;">
캔버스에서 컴포넌트를<br>선택하세요
</div>
</aside>
</div>
<!-- 상태바 -->
<div class="ab-statusbar">
<span id="ab-status-l" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">컴포넌트: 0개</span>
<span style="flex-shrink:0;">Template Builder v2</span>
</div>
<!-- ═══ 테이블 설정 모달 ═══ -->
<div class="ab-tbl-modal-backdrop" id="ab-tbl-modal-backdrop" onclick="abTblModalClose()"></div>
<div class="ab-tbl-modal" id="ab-tbl-modal">
<div class="ab-tbl-modal-header" style="position:relative;">
<div class="ab-tbl-modal-title">⚙ 테이블 설정</div>
<div class="ab-tbl-modal-subtitle">테이블의 컬럼, 필터, 그룹화를 설정합니다</div>
<button class="ab-tbl-modal-close" onclick="abTblModalClose()">&times;</button>
</div>
<div class="ab-tbl-modal-tabs">
<div class="ab-tbl-modal-tab active" data-tab="columns" onclick="abTblModalSwitchTab('columns',this)">컬럼 설정</div>
<div class="ab-tbl-modal-tab" data-tab="filters" onclick="abTblModalSwitchTab('filters',this)">필터 설정</div>
<div class="ab-tbl-modal-tab" data-tab="groups" onclick="abTblModalSwitchTab('groups',this)">그룹 설정</div>
</div>
<div class="ab-tbl-modal-body" id="ab-tbl-modal-body">
<!-- JS가 채움 -->
</div>
<div class="ab-tbl-modal-footer">
<button class="ab-tbl-modal-btn" onclick="abTblModalClose()">취소</button>
<button class="ab-tbl-modal-btn primary" onclick="abTblModalSave()">저장</button>
</div>
</div>
</div>
</main>
@@ -1056,17 +1312,18 @@ html:not(.dark) .ab-popup-dialog{background:#fff;box-shadow:0 20px 60px rgba(0,0
<script>
/* ═══ 어드민 빌더 — 뷰 전환 + 테이블 목록 (로직은 08-admin-builder.js) ═══ */
const AB_TABLES=[
{name:'inbound_mng',label:'입고관리',cols:12},{name:'order_management_test',label:'수주관리',cols:10},
{name:'order_management_test',label:'수주관리',cols:13},{name:'equipment_mng',label:'설비관리',cols:9},
{name:'customer_mng',label:'거래처관리',cols:13},{name:'item_info',label:'품목정보',cols:8},
{name:'purchase_order_mng',label:'발주관리',cols:10},{name:'user_info',label:'인사정보',cols:9},
{name:'equipment_mng',label:'설비관리',cols:9},{name:'item_info',label:'품목정보',cols:7},
{name:'inbound_mng',label:'입고관리',cols:10},
{name:'sales_order_mng',label:'수주(영업)',cols:14},{name:'supplier_mng',label:'공급처관리',cols:8},
{name:'customer_mng',label:'거래처관리',cols:11},{name:'bom',label:'BOM',cols:6},
{name:'warehouse_location',label:'창고/위치',cols:7},{name:'inspection_standard',label:'검사기준',cols:12},
{name:'process_mng',label:'공정관리',cols:8},{name:'shipment_management_test',label:'출하관리',cols:9},
{name:'work_order_mng',label:'작업지시',cols:11},{name:'quality_inspection',label:'품질검사',cols:9},
{name:'inventory_mng',label:'재고관리',cols:8},{name:'production_plan',label:'생산계획',cols:10},
{name:'dept_info',label:'부서정보',cols:5},{name:'company_mng',label:'회사정보',cols:12},
{name:'approval',label:'결재관리',cols:8},{name:'board_posts',label:'게시판',cols:7},
{name:'bom',label:'BOM',cols:6},{name:'warehouse_location',label:'창고/위치',cols:7},
{name:'inspection_standard',label:'검사기준',cols:12},{name:'process_mng',label:'공정관리',cols:8},
{name:'shipment_management_test',label:'출하관리',cols:9},{name:'work_order_mng',label:'작업지시',cols:11},
{name:'quality_inspection',label:'품질검사',cols:9},{name:'inventory_mng',label:'재고관리',cols:8},
{name:'production_plan',label:'생산계획',cols:10},{name:'dept_info',label:'부서정보',cols:5},
{name:'company_mng',label:'회사정보',cols:12},{name:'approval',label:'결재관리',cols:8},
{name:'board_posts',label:'게시판',cols:7},
];
function showAdminView(view){
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
# INVYONE 빌더 세션 로그 (2026-04-08~09)
> 이전 세션 로그: `2026-04-08-invyone-dev-session-log.md` (대시보드 mockup + 제어 모드 + 첫 빌더 프로토타입)
> 이번 세션: 로우코드 정의 토론 → 규격 재설계 → 빌더 구조 전면 개편
---
## 주요 결정사항 (시간순)
### 1. 로우코드 정의 토론
"INVYONE이 대체 뭐냐"에 대한 답을 정리:
- 로우코드 레벨 정의: L0(노코드) ~ L3(프레임워크)
- INVYONE 위치: L0~L1 (중소) + L1~L2 (중견+)
- 타겟 사용자: 중소기업 업무담당자 (코드 모름) + 중견 이상 IT팀 (빌더 사용)
- 결론: **개발자 빌더 먼저, 사용자 화면 나중에**
### 2. PM 원본 분석
`~/다운로드/INVYONE개발/` 폴더의 HTML 67개 파일 분석:
- 총 110,515줄, 평균 1,647줄/파일
- 구성: JS 67% / CSS 19% / HTML 14%
- 패턴: 55/66 CRUD 버튼, 54/66 검색 섹션, 51/66 테이블/그리드
- 결론: 대부분의 화면이 검색+테이블+CRUD 조합 → 빌더로 자동 생성 가능
### 3. vex DB 분석
test-vex (bexplorer-prod) DB 핵심 테이블:
- `screen_definitions`: 화면 정의 (screen_id, table_name, company_code)
- `screen_layouts_v3`: 레이아웃 JSON (components 배열, 12컬럼 그리드)
- `table_type_columns`: 핵심 메타데이터 (회사별 컬럼 타입/라벨/표시 설정, input_type 15종)
- `table_column_category_values`: 드롭다운 선택지
- `table_relationships`: 테이블 간 관계
- `node_flows`: 제어 플로우 (ReactFlow 노드 64개, 10종 노드 타입)
- `button_action_standards`: 12종 액션
발견: DB 컬럼이 전부 VARCHAR, 실제 타입은 table_type_columns.input_type에서 정의
### 4. vex 방식 문제점 파악 → 개선 방향
vex 문제:
- ColumnConfig 354줄 + FilterConfig + FormField 전부 다른 규격
- 컴포넌트 간 데이터 전달 시 변환 필요 → 에러 다발
- 목록/등록/수정 = 3개 화면 따로 만들어 연결
개선 방향:
- 추상화 레벨 올리기 (컴포넌트 레벨 → 필드 레벨)
- "사용자는 필드를 배치하는 게 아니라, FieldConfig를 소비하는 컴포넌트를 배치한다"
### 5. 컴포넌트 규격 신규 설계
FieldConfig + DataPort + Template 신규 규격 확정:
- **FieldConfig** (~30줄) — 테이블/폼/검색 전부 하나의 규격
- **DataPort** — output→input 표준 통신 (row/rows/value/params)
- **Template** = 3뷰 내장 (목록+등록+수정, 같은 fields 공유)
- vex 규격 호환 고려하지 않기로 결정 (신규 설계)
산출물: `notes/gbpark/2026-04-08-invyone-component-spec.md` (584줄)
### 6. 팔레트 정리: 26개 → 8개
초기 팔레트 (11종):
table, form, search, button, button-bar, tabs, split-panel, title, stats, divider, pagination
정리 과정:
- 레이아웃 (tabs, split-panel) → 프리셋이 처리 (내부 모델 유지, 팔레트에서 숨김)
- 필드 → FieldConfig.type (컴포넌트가 아님)
- 개별 버튼 종류 → ActionType (버튼 컴포넌트의 속성)
- button-bar → 버튼으로 통합
- pagination → 테이블 내장
- 마스터-디테일 → 테이블+폼 조합 + DataPort 연결
최종 8종: **데이터 테이블, 검색 필터, 입력 폼, 버튼, 통계 카드, 차트, 텍스트/제목, 구분선**
### 7. 렌더링 계약 / computed 파서 / required 검증
- 렌더링 계약: FieldType별 테이블/폼/검색 렌더링 매핑 테이블 명시 (암묵적 규칙 금지)
- computed: eval/new Function 절대 금지 → 스택 기반 수식 파서만 허용
- required: 0, false는 유효한 값 (empty = null/undefined/'' 만)
---
## 구현한 파일들
### mockup 폴더 (`notes/gbpark/2026-04-08-invyone-mockup/`)
총 23파일, 12,142줄:
| 파일 | 줄수 | 설명 |
|---|---|---|
| **HTML** | | |
| `index.html` | 1,370 | 대시보드 + 제어 모드 + 어드민 빌더 진입점 |
| `builder-v2.html` | 1,032 | 템플릿 빌더 v2 standalone (3뷰, 프리셋, 필드 on/off) |
| `developer.html` | 701 | 개발자 모드 standalone 참고용 |
| `renderer-proto.html` | 1,455 | FieldConfig 렌더러 프로토타입 |
| `spec-viewer.html` | 2,146 | 컴포넌트 규격 인터랙티브 뷰어 |
| **CSS** (css/) | | |
| `01-tokens.css` | 62 | v5 토큰 + cosmic 배경 |
| `02-shell.css` | 189 | 헤더/탭/사이드바 |
| `03-canvas.css` | 145 | 캔버스/카드/드래그/리사이즈 |
| `04-settings.css` | 121 | 카드 설정 패널 |
| `05-widgets.css` | 122 | 위젯 스타일 |
| `06-modals.css` | 90 | 라이브러리 모달 |
| `07-control-mode.css` | 189 | 제어 모드 |
| `08-rule-builder.css` | 184 | 제어 노드 빌더 |
| `09-developer.css` | 304 | 개발자 모드 |
| **JS** (js/) | | |
| `01-shell.js` | 123 | 테마/모드/사이드바 |
| `02-canvas.js` | 141 | 편집모드/드래그/리사이즈 |
| `03-settings.js` | 94 | 설정 패널 |
| `04-templates.js` | 173 | 위젯 렌더러/카드 빌드 |
| `05-state.js` | 249 | 대시보드 상태/저장/복원 |
| `06-control-mode.js` | 996 | 제어 모드 (카드 흐름, 트리 확산) |
| `07-rule-builder.js` | 752 | 제어 노드 빌더 (16종 노드) |
| `08-admin-builder.js` | 1,481 | 어드민 빌더 (최대 파일) |
| `99-init.js` | 23 | init IIFE |
| `README.md` | 131 | mockup 가이드 |
### 스펙 문서
| 파일 | 설명 |
|---|---|
| `2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 v1.0 (FieldConfig, DataPort, Template) |
| `2026-04-08-lowcode-platform-spec.md` | 로우코드 플랫폼 SPEC v1.0 (역할, 레벨, 67개 화면 분석) |
| `2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 (본 세션 최종 정리) |
---
## 시행착오
### 블록 배경 투명으로 만들었다가 복원
빌더 블록의 배경을 투명하게 만들어 캔버스가 비쳐 보이게 했으나, 가독성이 심각하게 떨어져서 원래 불투명 배경으로 복원.
### vex 호환 고려했다가 폐기
처음에는 vex의 components 배열 형식을 출력하는 방향으로 builder-v2를 만들었음. 그러나 vex의 ColumnConfig(354줄)과 분리된 규격들이 근본적 문제이므로, 호환을 포기하고 FieldConfig 단일 규격으로 신규 설계.
### 자동생성 집착 → 기능 구현 우선으로 전환
테이블 선택만으로 화면이 뿅 나오는 "자동생성"에 집착했으나, 실제로는 SI 프로젝트에서 커스텀이 필수. 자동생성은 시작점일 뿐이고, 빌더의 수동 구성 기능이 더 중요하다는 결론.
### 팔레트에 필드/레이아웃/개별버튼 넣었다가 제거
- 필드를 팔레트에 넣으면 "필드를 드래그해서 배치"하는 UX가 되는데, 이건 FieldConfig 소비 모델과 맞지 않음
- 레이아웃(tabs, split-panel)은 프리셋이 처리하는 것이 맞음 (개발자가 직접 배치할 필요 없음)
- 개별 버튼 종류(저장/삭제/엑셀 등)를 팔레트에 넣으면 팔레트가 비대해짐 → ActionType으로 통합
### 코스믹 디자인을 개발자 도구에 적용
v5 Cosmic Glassmorphism은 사용자 대시보드용. 개발자 모드는 IDE 스타일 (중성 다크 그레이, 글로우 없음)이 맞다.
---
## 다음 할 일 (TODO)
### 빌더 UX 개선
- [ ] 테이블 설정 모달 UX — 더블클릭으로 열 것인지, 아이콘 클릭인지 결정 필요
- [ ] 블록 더블클릭 → 설정 모달 열기 (현재는 클릭→속성 패널만)
- [ ] 등록/수정 팝업 뷰 편집 개선 — 현재 오버레이 기본만 있음
- [ ] 반응형 미리보기 — 모바일/태블릿 뷰
### React 이식
- [ ] React 이식 시작 조건 정의 — mockup 어디까지 완성하면 이식 시작할 것인가
- [ ] FieldConfig 타입스크립트 정의 파일 작성
- [ ] 렌더러 프로토타입 → React 컴포넌트로 변환
- [ ] DataPort 이벤트 버스 구현
### Phase 2 준비
- [ ] ValidationRule 배열 설계
- [ ] 필드 간 연동 규칙
- [ ] 조건부 표시/숨김
---
## 참고 파일 위치
| 파일 | 설명 |
|---|---|
| `notes/gbpark/2026-04-08-invyone-mockup/` | mockup 전체 폴더 |
| `notes/gbpark/2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 |
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 로우코드 플랫폼 SPEC |
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 |
| `notes/gbpark/2026-04-08-invyone-dev-session-log.md` | 이전 세션 로그 |
@@ -0,0 +1,234 @@
# INVYONE 아키텍처 결정 문서
> **작성일**: 2026-04-09
> **기반**: 2026-04-08~09 빌더 설계 세션
> **상태**: 확정 (Phase 1 기준)
---
## 1. 로우코드 플랫폼 정의
**"사용자는 필드를 배치하는 게 아니라, FieldConfig를 소비하는 컴포넌트를 배치한다"**
FieldConfig 단일 규격이 전체 시스템의 중심이다. 테이블 컬럼, 폼 입력, 검색 조건이 전부 같은 FieldConfig를 공유하며, 컴포넌트는 이 규격을 소비하여 각자의 방식으로 렌더링한다.
vex의 접근: 컴포넌트마다 별도 규격 (ColumnConfig 354줄, FilterConfig, FormField 등) → 변환 필요 → 에러 폭발
invyone의 접근: FieldConfig 하나 (~30줄) → 변환 불필요 → 컴포넌트 간 데이터 직통
---
## 2. 역할 모델
| 역할 | 타겟 | 레벨 | 하는 일 |
|---|---|---|---|
| **사용자** (업무담당자) | 중소기업 | L0~L1 | 완성된 화면 사용 + 간단 설정 (컬럼 순서, 필터 조건 등) |
| **개발자** (IT팀) | 중견 이상 | L1~L2 | 빌더에서 화면 구성 (컴포넌트 배치, 필드 설정, DataPort 연결) |
중소기업은 개발자 없이 사용자만으로 운영 가능해야 한다. 개발자 빌더를 먼저 만들고, 사용자 화면은 그 위에 올린다.
---
## 3. 컴포넌트 규격
### 3.1 FieldConfig (~30줄)
테이블/폼/검색 전부 같은 규격. 핵심 필드:
```
column(식별), label(표시), type(FieldType), visible, order, width, align,
required, editable, defaultValue, placeholder,
options(select용), ref(entity FK), format(포맷), computed(자동계산),
pk, system, searchable, sortable
```
FieldType 10종: `text | number | date | datetime | select | entity | checkbox | textarea | file | code`
### 3.2 ComponentType 8종 (팔레트)
| 컴포넌트 | 역할 | FieldConfig 사용 |
|---|---|---|
| **데이터 테이블** | 목록 표시, 행 선택 | fields를 컬럼으로 렌더 |
| **검색 필터** | 조건 입력, 테이블에 params 전달 | fields를 검색 입력으로 렌더 |
| **입력 폼** | 데이터 입력/수정 | fields를 폼 필드로 렌더 |
| **버튼** | 액션 실행 (ActionType 12종) | - |
| **통계 카드** | KPI/집계 표시 | fields의 aggregation |
| **차트** | 시각화 | fields 기반 데이터 |
| **텍스트/제목** | 라벨, 설명, 구분 텍스트 | - |
| **구분선** | 시각적 분리 | - |
### 3.3 팔레트 정리 (26개 → 8개)
제거된 것들과 이유:
| 제거 항목 | 이유 / 대체 |
|---|---|
| 레이아웃 (tabs, split-panel) | 프리셋이 처리. 내부 모델은 유지하되 팔레트에서 숨김 |
| 필드 | FieldConfig.type이 처리. 컴포넌트가 아님 |
| 개별 버튼 종류 | ActionType (버튼 컴포넌트의 속성) |
| button-bar | 버튼 컴포넌트로 통합 |
| pagination | 테이블에 내장 |
| 마스터-디테일 | 테이블+폼 조합 + DataPort 연결로 구현 |
### 3.4 DataPort 통신
컴포넌트 간 직접 참조 금지. 표준화된 포트로 통신:
```
[검색] --searchParams(params)--> [테이블] --selectedRow(row)--> [폼]
--selectedRows(rows)--> [버튼(삭제)]
```
DataPortType: `row | rows | value | params`
output→input 매칭으로 연결. 데이터 형식이 동일하므로 변환 불필요.
### 3.5 Template = 3뷰 내장
```
Template {
fields: FieldConfig[] ← 유일한 진실의 원천, 3뷰가 공유
views: {
list: ViewConfig ← 목록 화면 (컴포넌트 배열)
create: ViewConfig ← 등록 팝업
edit: ViewConfig ← 수정 팝업 (create 상속 가능)
}
connections: Connection[] ← DataPort 연결
}
```
vex는 목록/등록/수정을 3개 화면으로 따로 만들어야 했다. invyone은 하나의 Template에 3뷰가 내장되어 같은 fields를 공유한다.
---
## 4. 렌더링 계약 (스펙 2.2)
FieldType별로 테이블/폼/검색에서 다르게 렌더된다. 이 차이는 암묵적이면 안 되고 명시적이어야 한다:
| FieldType | 테이블 | 폼 | 검색 |
|---|---|---|---|
| `text` | 텍스트 그대로 | `<input type="text">` | `<input>` (부분 일치) |
| `number` | format 적용 (#,##0) | `<input type="number">` | `<input>` (범위: min~max) |
| `date` | format 적용 | DatePicker (단일) | **DateRangePicker (범위)** |
| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker |
| `select` | 텍스트 그대로 | `<select>` (단일) | **MultiSelect (다중)** |
| `entity` | ref.displayColumn 표시 | 검색 팝업 | 검색 팝업 (단일) |
| `checkbox` | check/cross 아이콘 | `<checkbox>` | `<select>` (전체/Y/N) |
| `textarea` | 말줄임 (...) | `<textarea>` | `<input>` (부분 일치) |
| `file` | 파일명 링크 | 파일 업로드 | -- (검색 불가) |
| `code` | 텍스트 그대로 | readonly | `<input>` (완전 일치) |
핵심: 검색에서 date→범위, select→다중으로 바뀌는 것은 SearchConfig의 렌더링 규칙이지 FieldType의 암묵적 속성이 아니다.
---
## 5. computed 실행 모델
**eval() / new Function() 절대 금지.**
```
허용: 'quantity * unit_price', 'SUM(order_items.amount)'
금지: 'fetch("/api")', 'console.log()', 'document.cookie'
```
구현: 스택 기반 수식 파서 (AST 또는 화이트리스트 토큰)
- 허용 토큰: 숫자, 필드명, +, -, *, /, (, ), SUM, COUNT, AVG, MIN, MAX
- 그 외 전부 거부
- 별도 유틸 (`lib/formula-parser.ts`)로 분리
---
## 6. required 검증
**0, false는 유효한 값이다. empty = null / undefined / '' 만.**
```typescript
function isFieldEmpty(value: any): boolean {
return value === null || value === undefined || value === '';
}
```
JavaScript의 falsy 함정과 다른 비즈니스 규칙. 수량 0, 체크박스 false는 의미 있는 값.
---
## 7. 데이터 흐름 (전체 그림)
```
[table_type_columns DB]
| (자동 로드 / 수동 정의)
v
[Template.fields: FieldConfig[]] <-- 유일한 진실의 원천
| (공유)
+---------+---------+
v v v
목록 등록 수정 <-- 3뷰가 같은 fields 공유
| | |
v v v
[Component[]] <-- 각 뷰의 컴포넌트 배열
|
v
[DataPort 연결] <-- 검색->테이블->폼 자동 흐름
|
v
[렌더러] <-- FieldConfig.type 보고 적절한 UI
```
---
## 8. vex와의 차이
| 항목 | vex | invyone |
|---|---|---|
| 필드 규격 | ColumnConfig(354줄) + FilterConfig + FormField = 전부 다름 | **FieldConfig 하나 (~30줄)** |
| 컴포넌트 간 통신 | 자체 구현, 컴포넌트마다 다름 | **DataPort 표준 프로토콜** |
| 컴포넌트 타입 | 33종 (v2-*), 각각 다른 규격 | **8종, 공통 base + 간결한 TypeConfig** |
| 테이블 설정 | TableListConfig 354줄 | **TableConfig ~15줄** |
| 화면 구조 | 목록/등록/수정 = 3개 화면 따로 만들어 연결 | **Template 1개에 3뷰 내장** |
| 필드 공유 | 화면마다 따로 정의 | **Template.fields를 모든 뷰가 공유** |
| 데이터 전달 | 컴포넌트별 커스텀 이벤트 | **output->input 자동 매칭** |
---
## 9. Phase 로드맵
### Phase 1: 레이아웃 + CRUD (현재)
- FieldConfig 정의, 빌더 UI, 프리셋 시스템
- 테이블/폼/검색 렌더러
- DataPort 기본 연결
- CRUD API 자동 생성
### Phase 2: 규칙 엔진
- 유효성 검증 (ValidationRule 배열)
- 필드 간 연동 (A 변경 시 B 자동 계산)
- 조건부 표시/숨김
- SearchConfig fieldOverrides
### Phase 3: 제어 플로우
- 노드 에디터 (수주 결재 완료 -> 발주 자동 생성 등)
- 회사별 자동화 체인
- test-vex의 node_flows 진화 버전
---
## 10. 기술 스택
| 영역 | 기술 |
|---|---|
| Frontend | Next.js (React) |
| Backend | Spring Boot (Java) |
| DB | PostgreSQL + JSONB |
| 빌더 | HTML mockup -> React 이식 예정 |
| 상태관리 | Zustand 또는 Context (미정) |
| 수식 파서 | 스택 기반 커스텀 파서 (lib/formula-parser.ts) |
---
## 참고 문서
| 문서 | 위치 |
|---|---|
| 컴포넌트 규격 v1.0 | `notes/gbpark/2026-04-08-invyone-component-spec.md` |
| 로우코드 플랫폼 SPEC | `notes/gbpark/2026-04-08-lowcode-platform-spec.md` |
| mockup 폴더 | `notes/gbpark/2026-04-08-invyone-mockup/` |
| 빌더 세션 로그 | `notes/gbpark/2026-04-09-builder-session-log.md` |
| 이전 세션 로그 | `notes/gbpark/2026-04-08-invyone-dev-session-log.md` |