From c6e81c452022ec28de2f1a4338cd54fcac62801c Mon Sep 17 00:00:00 2001 From: gbpark Date: Thu, 9 Apr 2026 00:39:11 +0900 Subject: [PATCH] 22 --- frontend/types/invyone-component.ts | 678 +++++ .../2026-04-08-invyone-component-spec.md | 584 ++++ .../2026-04-08-invyone-mockup/builder-v2.html | 1032 ++++++++ .../2026-04-08-invyone-mockup/index.html | 401 ++- .../js/08-admin-builder.js | 2353 ++++++++++------- .../renderer-proto.html | 1456 ++++++++++ .../spec-viewer.html | 2146 +++++++++++++++ .../gbpark/2026-04-09-builder-session-log.md | 179 ++ .../gbpark/2026-04-09-invyone-architecture.md | 234 ++ 9 files changed, 8033 insertions(+), 1030 deletions(-) create mode 100644 frontend/types/invyone-component.ts create mode 100644 notes/gbpark/2026-04-08-invyone-component-spec.md create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/builder-v2.html create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/renderer-proto.html create mode 100644 notes/gbpark/2026-04-08-invyone-mockup/spec-viewer.html create mode 100644 notes/gbpark/2026-04-09-builder-session-log.md create mode 100644 notes/gbpark/2026-04-09-invyone-architecture.md diff --git a/frontend/types/invyone-component.ts b/frontend/types/invyone-component.ts new file mode 100644 index 00000000..bff43e1b --- /dev/null +++ b/frontend/types/invyone-component.ts @@ -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; + /** 중형 화면 (≥768px) */ + md?: Partial; + /** 소형 화면 (<768px) */ + sm?: Partial; +} + +/** + * 컴포넌트의 데이터 소스 바인딩. + */ +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 (단일 행) | + * | rows | Record[] (복수 행) | + * | value | any (단일 값) | + * | params | Record (검색 파라미터) | + */ +export type DataPortType = + | 'row' // 단일 행: Record + | 'rows' // 복수 행: Record[] + | 'value' // 단일 값: any + | 'params'; // 검색 파라미터: Record + +/** + * 컴포넌트가 데이터를 주고받는 포트. + * + * 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 extends Omit { + 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; +} diff --git a/notes/gbpark/2026-04-08-invyone-component-spec.md b/notes/gbpark/2026-04-08-invyone-component-spec.md new file mode 100644 index 00000000..56e47e52 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-component-spec.md @@ -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`)의 데이터를 주고받으므로 변환 불필요. + +### 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' } + +→ 테이블에서: 2026-04-08 (format 적용) +→ 폼에서: +→ 검색에서: +→ 속성에서: [수주일] date ☑visible ☑required +``` + +렌더러가 FieldConfig의 `type`을 보고 알아서 적절한 UI를 그린다. 필드 규격은 하나. + +### 2.2 렌더링 계약 (★ 스키마-런타임 바인딩 규칙) + +**렌더러마다 FieldConfig를 해석하는 규칙이 다르다. 이 차이는 암묵적이면 안 되고 명시적이어야 한다.** + +| FieldType | 테이블 렌더 | 폼 렌더 | 검색 렌더 | +|---|---|---|---| +| `text` | 텍스트 그대로 | `` | `` (부분 일치) | +| `number` | format 적용 (#,##0) | `` | `` (범위: min~max) | +| `date` | format 적용 (YYYY-MM-DD) | DatePicker (단일) | **DateRangePicker (범위)** | +| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker | +| `select` | 텍스트 그대로 | `` (전체/✓/✗) | +| `textarea` | 말줄임 (…) | ``; + } else if (f.type === 'number') { + html += ``; + } else { + html += ``; + } + + html += ''; + }); + html += ''; + container.innerHTML = html; + + // form mode indicator + const indicator = document.getElementById('form-mode-indicator'); + indicator.className = 'form-mode-badge ' + formMode; + indicator.textContent = formMode.toUpperCase(); + + // form actions + renderFormActions(mode); +} + +function renderFormActions(mode) { + const container = document.getElementById('form-actions'); + if (mode === 'view') { + container.innerHTML = selectedRowIndex >= 0 + ? '' + : '테이블에서 행을 클릭하세요'; + } else if (mode === 'edit') { + container.innerHTML = ''; + } else if (mode === 'create') { + container.innerHTML = ''; + } +} + +/* ═══════════════════════════════════════════════════════ + 3. DATAPORT INTERACTIONS + ═══════════════════════════════════════════════════════ */ + +function selectRow(idx) { + selectedRowIndex = idx; + const row = filteredData[idx]; + logDataPort('selectedRow → form.loadRow', `행 선택: ${row.order_no} (${getEntityDisplay(fields.find(f=>f.column==='customer_id'), row.customer_id)})`); + renderTable(); + renderForm(row, 'view'); +} + +function executeSearch() { + const searchFields = fields.filter(f => f.searchable); + const params = {}; + let desc = []; + + searchFields.forEach(f => { + if (f.type === 'date') { + const from = document.getElementById(`search-${f.column}-from`).value; + const to = document.getElementById(`search-${f.column}-to`).value; + if (from) { params[f.column + '_from'] = from; desc.push(`${f.label}>=${from}`); } + if (to) { params[f.column + '_to'] = to; desc.push(`${f.label}<=${to}`); } + } else if (f.type === 'entity') { + const val = document.getElementById(`search-${f.column}-value`).value; + if (val) { params[f.column] = val; desc.push(`${f.label}=${val}`); } + } else if (f.type === 'select') { + const checkboxes = document.querySelectorAll(`#ms-dropdown-${f.column} input:checked`); + const vals = Array.from(checkboxes).map(cb => cb.value); + if (vals.length > 0) { params[f.column] = vals; desc.push(`${f.label}=[${vals.join(',')}]`); } + } else { + const val = document.getElementById(`search-${f.column}`).value.trim(); + if (val) { params[f.column] = val; desc.push(`${f.label}=${val}`); } + } + }); + + // filter locally + filteredData = tableData.filter(row => { + for (const f of searchFields) { + if (f.type === 'date') { + if (params[f.column + '_from'] && row[f.column] < params[f.column + '_from']) return false; + if (params[f.column + '_to'] && row[f.column] > params[f.column + '_to']) return false; + } else if (f.type === 'entity') { + if (params[f.column] && row[f.column] !== params[f.column]) return false; + } else if (f.type === 'select') { + if (params[f.column] && params[f.column].length > 0 && !params[f.column].includes(row[f.column])) return false; + } else { + if (params[f.column] && !String(row[f.column]).includes(params[f.column])) return false; + } + } + return true; + }); + + selectedRowIndex = -1; + logDataPort('searchParams → table', `검색 실행: {${desc.length > 0 ? desc.join(', ') : '전체'}} → ${filteredData.length}건`); + renderTable(); + renderForm(null, 'view'); +} + +function resetSearch() { + fields.filter(f => f.searchable).forEach(f => { + if (f.type === 'date') { + document.getElementById(`search-${f.column}-from`).value = ''; + document.getElementById(`search-${f.column}-to`).value = ''; + } else if (f.type === 'entity') { + document.getElementById(`search-${f.column}`).value = ''; + document.getElementById(`search-${f.column}-value`).value = ''; + } else if (f.type === 'select') { + document.querySelectorAll(`#ms-dropdown-${f.column} input`).forEach(cb => cb.checked = false); + document.getElementById(`ms-display-${f.column}`).textContent = '전체'; + } else { + document.getElementById(`search-${f.column}`).value = ''; + } + }); + filteredData = [...tableData]; + selectedRowIndex = -1; + logDataPort('searchParams → table', '검색 초기화 → 전체 데이터 표시'); + renderTable(); + renderForm(null, 'view'); +} + +function sortTable(column) { + if (sortColumn === column) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + sortColumn = column; + sortDirection = 'asc'; + } + const f = fields.find(ff => ff.column === column); + filteredData.sort((a, b) => { + let va = a[column], vb = b[column]; + if (f.type === 'number') { va = Number(va); vb = Number(vb); } + if (f.type === 'entity' && f.ref) { va = getEntityDisplay(f, va); vb = getEntityDisplay(f, vb); } + if (va < vb) return sortDirection === 'asc' ? -1 : 1; + if (va > vb) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + selectedRowIndex = -1; + logDataPort('table.sort', `정렬: ${f.label} ${sortDirection.toUpperCase()}`); + renderTable(); +} + +function startEdit() { + if (selectedRowIndex < 0) return; + const row = filteredData[selectedRowIndex]; + logDataPort('form.mode', `수정 모드 전환: ${row.order_no}`); + renderForm(row, 'edit'); +} + +function startCreate() { + selectedRowIndex = -1; + logDataPort('form.mode', '신규 생성 모드'); + renderTable(); + renderForm(null, 'create'); +} + +function cancelForm() { + logDataPort('form.cancel', '편집 취소'); + if (selectedRowIndex >= 0) { + renderForm(filteredData[selectedRowIndex], 'view'); + } else { + renderForm(null, 'view'); + } +} + +function saveForm() { + const row = {}; + fields.forEach(f => { + if (f.system) { + row[f.column] = f.defaultValue; + return; + } + const el = document.getElementById(`form-${f.column}`); + if (!el) return; + if (f.type === 'number') { + row[f.column] = el.value === '' ? 0 : Number(el.value); + } else { + row[f.column] = el.value; + } + }); + + // compute + computeField(row); + + // validate required (★ 0, false는 유효한 값 — empty는 null/undefined/'' 만) + function isFieldEmpty(v) { return v === null || v === undefined || v === ''; } + const missing = fields.filter(f => f.required && !f.system && !f.computed && !f.pk).filter(f => { + return isFieldEmpty(row[f.column]); + }); + if (missing.length > 0) { + alert('필수 항목을 입력해주세요: ' + missing.map(f => f.label).join(', ')); + return; + } + + if (formMode === 'create') { + tableData.push(row); + nextOrderNo++; + logDataPort('savedRow → table.refresh', `신규 저장: ${row.order_no} (금액: ${formatValue(row.amount, fields.find(f=>f.column==='amount'))}원)`); + } else { + // find original index in tableData + const orderNo = filteredData[selectedRowIndex].order_no; + const origIdx = tableData.findIndex(r => r.order_no === orderNo); + if (origIdx >= 0) { + row.order_no = orderNo; // keep original PK + tableData[origIdx] = row; + logDataPort('savedRow → table.refresh', `수정 저장: ${row.order_no}`); + } + } + + // re-apply current search + executeSearch(); + // try to re-select + const newIdx = filteredData.findIndex(r => r.order_no === row.order_no); + if (newIdx >= 0) { + selectedRowIndex = newIdx; + renderTable(); + renderForm(filteredData[newIdx], 'view'); + } +} + +function deleteRow() { + if (selectedRowIndex < 0) return; + const row = filteredData[selectedRowIndex]; + if (!confirm(`"${row.order_no}" 를 삭제하시겠습니까?`)) return; + const origIdx = tableData.findIndex(r => r.order_no === row.order_no); + if (origIdx >= 0) tableData.splice(origIdx, 1); + logDataPort('deleteRow → table.refresh', `삭제: ${row.order_no}`); + selectedRowIndex = -1; + executeSearch(); + renderForm(null, 'view'); +} + +function onFormNumberChange() { + // auto-compute amount when quantity or unit_price changes + const qtyEl = document.getElementById('form-quantity'); + const priceEl = document.getElementById('form-unit_price'); + const amtEl = document.getElementById('form-amount'); + if (qtyEl && priceEl && amtEl) { + const qty = Number(qtyEl.value) || 0; + const price = Number(priceEl.value) || 0; + const amt = qty * price; + amtEl.value = amt.toLocaleString('ko-KR'); + } +} + +/* ═══════════════════════════════════════════════════════ + ENTITY POPUP + ═══════════════════════════════════════════════════════ */ +let entityPopupTarget = null; // { column, context: 'form'|'search' } + +function openEntityPopup(column, context) { + const f = fields.find(ff => ff.column === column); + if (!f || !f.ref) return; + entityPopupTarget = { column, context, field: f }; + const overlay = document.getElementById('entity-popup-overlay'); + document.getElementById('ep-title').textContent = f.label + ' 검색'; + document.getElementById('ep-search').value = ''; + overlay.style.display = 'flex'; + renderEntityPopupList(''); + document.getElementById('ep-search').focus(); +} + +function closeEntityPopup() { + document.getElementById('entity-popup-overlay').style.display = 'none'; + entityPopupTarget = null; +} + +function filterEntityPopup() { + const q = document.getElementById('ep-search').value.trim().toLowerCase(); + renderEntityPopupList(q); +} + +function renderEntityPopupList(query) { + if (!entityPopupTarget) return; + const f = entityPopupTarget.field; + const data = entityData[f.ref.table] || []; + const listEl = document.getElementById('ep-list'); + + const filtered = data.filter(d => { + if (!query) return true; + return (f.ref.searchColumns || [f.ref.displayColumn]).some(col => + String(d[col]).toLowerCase().includes(query) + ); + }); + + listEl.innerHTML = filtered.map(d => + `
+ ${d[f.ref.displayColumn]} + ${d[f.ref.valueColumn]} +
` + ).join(''); +} + +function selectEntity(value) { + if (!entityPopupTarget) return; + const { column, context, field } = entityPopupTarget; + const display = getEntityDisplay(field, value); + + if (context === 'form') { + document.getElementById(`form-${column}`).value = value; + document.getElementById(`form-${column}-display`).value = display; + logDataPort('entity.select', `${field.label}: ${display} (${value})`); + } else if (context === 'search') { + document.getElementById(`search-${column}`).value = display; + document.getElementById(`search-${column}-value`).value = value; + logDataPort('entity.select', `검색 ${field.label}: ${display}`); + } + closeEntityPopup(); +} + +function clearSearchEntity(column) { + document.getElementById(`search-${column}`).value = ''; + document.getElementById(`search-${column}-value`).value = ''; +} + +/* ═══════════════════════════════════════════════════════ + MULTI-SELECT (for search) + ═══════════════════════════════════════════════════════ */ +function toggleMultiSelect(column) { + const dd = document.getElementById(`ms-dropdown-${column}`); + // close all others + document.querySelectorAll('.multi-select-dropdown.open').forEach(el => { + if (el.id !== `ms-dropdown-${column}`) el.classList.remove('open'); + }); + dd.classList.toggle('open'); +} + +function updateMultiSelect(column) { + const checkboxes = document.querySelectorAll(`#ms-dropdown-${column} input:checked`); + const vals = Array.from(checkboxes).map(cb => cb.value); + const displayEl = document.getElementById(`ms-display-${column}`); + displayEl.textContent = vals.length === 0 ? '전체' : vals.join(', '); +} + +// close multi-select on outside click +document.addEventListener('click', (e) => { + if (!e.target.closest('.multi-select')) { + document.querySelectorAll('.multi-select-dropdown.open').forEach(el => el.classList.remove('open')); + } +}); + +// close entity popup on overlay click +document.addEventListener('click', (e) => { + if (e.target.id === 'entity-popup-overlay') closeEntityPopup(); +}); + +/* ═══════════════════════════════════════════════════════ + THEME TOGGLE + ═══════════════════════════════════════════════════════ */ +function toggleTheme() { + const html = document.documentElement; + html.classList.toggle('dark'); + html.classList.toggle('light'); + document.getElementById('theme-label').textContent = html.classList.contains('dark') ? 'Dark' : 'Light'; +} + +/* ═══════════════════════════════════════════════════════ + RESET DEMO + ═══════════════════════════════════════════════════════ */ +function resetDemo() { + tableData = JSON.parse(JSON.stringify(originalTableData)); + filteredData = [...tableData]; + selectedRowIndex = -1; + sortColumn = null; + sortDirection = 'asc'; + nextOrderNo = 7; + document.getElementById('dataport-log').innerHTML = ''; + logDataPort('system', '데모 초기화 완료'); + renderSearch(); + renderTable(); + renderForm(null, 'view'); +} + +/* ═══════════════════════════════════════════════════════ + INIT + ═══════════════════════════════════════════════════════ */ +function init() { + renderFieldConfigJSON(); + renderSearch(); + renderTable(); + renderForm(null, 'view'); + logDataPort('system', 'FieldConfig 렌더러 프로토타입 초기화 완료'); + logDataPort('system', `필드 ${fields.length}개 로드 (searchable: ${fields.filter(f=>f.searchable).length}, visible: ${fields.filter(f=>f.visible).length})`); +} + +init(); + + + \ No newline at end of file diff --git a/notes/gbpark/2026-04-08-invyone-mockup/spec-viewer.html b/notes/gbpark/2026-04-08-invyone-mockup/spec-viewer.html new file mode 100644 index 00000000..091b6a44 --- /dev/null +++ b/notes/gbpark/2026-04-08-invyone-mockup/spec-viewer.html @@ -0,0 +1,2146 @@ + + + + + +INVYONE Component Spec v1.0 — Interactive Viewer + + + + + +
+ +
+ +
+ + +
+ + + + + + +
+ + +
+

INVYONE Component Spec v1.0

+

Draft — 2026-04-08 — Principle: Universality, Compatibility, Extensibility

+ +
+ Core Problem: In vex, ColumnConfig (354 lines), FilterConfig, and FormField are all different specs. + Data conversion between components causes errors. invyone unifies everything into FieldConfig (~30 lines). +
+ +

Design Principles

+ +
+
+
1
+
One FieldConfig, Everywhere
+
Table columns, form inputs, search filters — all use the same FieldConfig. No conversion needed between components.
+
+
+
2
+
DataPort Communication
+
Components don't reference each other directly. Standard DataPorts handle all data exchange via output → input matching.
+
+
+
3
+
3-Layer Config
+
BaseConfig (common) → TypeConfig (per-type) → InstanceConfig (per-instance override). Clean separation.
+
+
+ +

Data Flow Overview

+
+
[table_type_columns DB]
+
| (auto load)
+
[Template.fields: FieldConfig[]] ← single source of truth
+
| (shared)
+
+----+----+
+
| | |
+
List Create Edit ← 3 views share same fields
+
| | |
+
[Component[]] ← each view's component array
+
|
+
[DataPort connections] ← Search → Table → Form auto flow
+
|
+
[Renderer] ← FieldConfig.type → appropriate UI
+
+ +

Extension Points

+
+
+
+ New FieldType
+
1. Add to FieldType union
2. Add renderer for table/form/search
Done. FieldConfig structure unchanged.
+
+
+
+ New ComponentType
+
1. Add to ComponentType union
2. Define TypeConfig
3. Define default DataPorts
4. Implement renderer
Done. No impact on existing specs.
+
+
+
+ New ActionType
+
1. Add to ActionType union
2. Implement handler
Done.
+
+
+
+ + +
+

FieldType (10 Types)

+

Each FieldType determines how a field renders across table, form, and search contexts.

+ +
+ + +
+ + +
+

FieldConfig Properties

+

All properties of the unified FieldConfig interface (~30 lines replacing vex's 354-line ColumnConfig).

+ + + + + + + + + + + + + +
PropertyTypeRequiredDefaultDescriptionUsed By
+
+ + +
+

FieldConfig Live Editor

+

Edit the JSON on the left to see how the field renders as table cell, form input, and search filter.

+ +
+
+
✎ FieldConfig JSON
+ +
+
+
👁 Preview
+
+
+
+
+ + +
+

ComponentType (11 Types)

+

Click a type to view its TypeConfig details.

+ +
+ + +
+ + +
+

Component Base Structure

+

Every component, regardless of type, follows this structure.

+ +
+
Component
+
+-- id: string // unique identifier
+
+-- type: ComponentType // 11 types
+
+-- label: string // display name in builder
+
+-- position: Position // { x, y, w, h } on grid
+
+-- responsive?: Responsive // { lg?, md?, sm? } overrides
+
+-- dataSource?: DataSource // { table, mode }
+
+-- fields?: FieldConfig[] // the ONE field spec
+
+-- inputs?: DataPort[] // received data
+
+-- outputs?: DataPort[] // emitted data
+
+-- config: TypeConfig // type-specific settings
+
+ +

Position

+ + + + + + + + +
PropertyTypeDescription
xnumberHorizontal position (grid units)
ynumberVertical position (grid units)
wnumberWidth (grid units)
hnumberHeight (grid units)
+ +

Responsive Override

+

Breakpoint-specific position/size overrides. Only changed properties need to be specified.

+ + + + + + + +
BreakpointConditionType
lg≥ 1200pxPartial<Position>
md≥ 768pxPartial<Position>
sm< 768pxPartial<Position>
+ +

DataSource

+ + + + + + +
PropertyTypeDescription
tablestringBinding target table name
mode'read' | 'write' | 'readwrite'Access mode
+
+ + +
+

TypeConfig Details

+

Select a component type to see its specific configuration.

+ +
+
+
+ + +
+

DataPortType (4 Types)

+

DataPort standardizes all inter-component communication.

+ +
+
+
+
row
+
Single record: Record<string, any>
E.g., a selected table row passed to a form.
+
Record<string, any>
+
+
+
■■
+
rows
+
Multiple records: Record<string, any>[]
E.g., selected rows for bulk delete.
+
Record<string, any>[]
+
+
+
+
value
+
Single value: any
E.g., a button click event or total count.
+
any
+
+
+
+
params
+
Search parameters: Record<string, any>
E.g., search filters passed to a table.
+
Record<string, any>
+
+
+ +

DataPort Interface

+
TypeScriptinterface DataPort { + name: string; // port name (e.g. 'selectedRow') + type: DataPortType; // row | rows | value | params + connectedTo?: string; // connected component ID +}
+ +

Connection Interface

+
TypeScriptinterface Connection { + id: string; + from: { componentId: string; port: string; }; + to: { componentId: string; port: string; }; +}
+
+ + +
+

Default Ports by Component Type

+

Each component type has predefined input and output ports.

+ + + + + + + + + + +
ComponentDefault OutputsDefault Inputs
+
+ + +
+

DataPort Flow Diagram

+

Hover over components to highlight connected ports. Data dots animate along connections.

+ +
+ +
+ +
+ How it works: The builder visually sets connections. At runtime, an event bus automatically routes data through output → input matching. All data is Record<string, any> — no conversion needed. +
+
+ + +
+

Template Structure

+

One screen = one Template. List + create popup + edit popup in a single bundle.

+ +
+
Template
+
+-- templateId: string
+
+-- name: string
+
+-- category: string // sales, production, purchase...
+
+-- description?: string
+
+-- primaryTable: string // main binding table
+
+-- fields: FieldConfig[] // *** single source of truth ***
+
+-- views
+
| +-- list: ViewConfig // list screen
+
| +-- create: ViewConfig // create popup
+
| +-- edit: ViewConfig // edit popup (can extend create)
+
+-- connections: Connection[] // DataPort wiring
+
+-- companyCode: string
+
+-- version: number
+
+-- status: 'draft' | 'published'
+
+-- createdAt: string
+
+-- updatedAt: string
+
+ +

ViewConfig

+ + + + + + + +
PropertyTypeDescription
componentsComponent[]Components placed in this view
extends?'create'Edit view inherits from create view
size?{ w: number; h: number }Popup dimensions (for create/edit)
+ +
+ Key insight: Template.fields is the single source of truth. All three views (list, create, edit) share the same field definitions. Each view's components reference these shared fields — no duplication. +
+
+ + +
+

3 Views Visualization

+

Switch between list, create, and edit views to see their typical component layout.

+ +
+
List View
+
Create View
+
Edit View
+
+ +
+
+ + +
+

vex vs invyone Comparison

+

Side-by-side comparison of the two approaches.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aspectvexinvyone
Field SpecColumnConfig (354 lines) + FilterConfig + FormField — all differentFieldConfig (~30 lines) — one spec for everything
Inter-component CommunicationCustom implementation per component, all differentDataPort standard protocol — output → input matching
Component Types33 types (v2-*), each with unique spec11 types, shared base + concise TypeConfig
Table ConfigTableListConfig — 354 linesTableConfig — ~15 lines
Screen StructureList / Create / Edit = 3 separate screens1 Template with 3 embedded views
Field SharingDefined separately per screenTemplate.fields shared by all views
Data TransferCustom events per component — conversion requiredoutput → input auto-matching — no conversion
+ +

Code Comparison: Field Definition

+
+
+
vex ColumnConfig (excerpt from 354 lines)
+
// vex: ColumnConfig has 354 lines in types.ts +// Same "order_date" field needs 3 different definitions: + +// 1. For table (ColumnConfig): +{ columnName: 'order_date', + header: 'Order Date', + dataType: 'date', + sortable: true, + filterable: true, + width: 120, + format: 'YYYY-MM-DD', + align: 'center', + // ... 20+ more properties } + +// 2. For form (FormField) - different structure! +{ name: 'order_date', + label: 'Order Date', + inputType: 'datepicker', + required: true, + // ... different props } + +// 3. For search (FilterConfig) - yet another! +{ field: 'order_date', + filterType: 'dateRange', + // ... different again }
+
+
+
invyone FieldConfig (complete)
+
// invyone: ONE definition for everything + +{ + column: 'order_date', + label: 'Order Date', + type: 'date', + visible: true, + order: 3, + required: true, + editable: true, + format: 'YYYY-MM-DD', + searchable: true, + sortable: true +} + +// Table: renders as <td>2026-04-08</td> +// Form: renders as <DatePicker /> +// Search: renders as <DateRangePicker /> +// +// Same object. No conversion. +// The renderer decides how to display it.
+
+
+ +

Data Transfer Comparison

+
+
+
vex: Conversion Required
+
// Table row click → form load +// Need to convert ColumnConfig row to FormField format +const formData = convertTableRowToFormData( + selectedRow, + columnConfigs, // table format + formFields // form format +); +// Conversion code = error-prone +// What if a field name doesn't match?
+
+
+
invyone: Zero Conversion
+
// Table row click → form load +// DataPort sends the row as-is +// table.output(selectedRow) → form.input(loadRow) + +// Both use the same FieldConfig +// Same column names, same types +// The renderer just switches mode: +// table mode → display cell +// form mode → input widget
+
+
+
+ +
+
+ + + + diff --git a/notes/gbpark/2026-04-09-builder-session-log.md b/notes/gbpark/2026-04-09-builder-session-log.md new file mode 100644 index 00000000..a18e2176 --- /dev/null +++ b/notes/gbpark/2026-04-09-builder-session-log.md @@ -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` | 이전 세션 로그 | diff --git a/notes/gbpark/2026-04-09-invyone-architecture.md b/notes/gbpark/2026-04-09-invyone-architecture.md new file mode 100644 index 00000000..4b678327 --- /dev/null +++ b/notes/gbpark/2026-04-09-invyone-architecture.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` | 텍스트 그대로 | `` | `` (부분 일치) | +| `number` | format 적용 (#,##0) | `` | `` (범위: min~max) | +| `date` | format 적용 | DatePicker (단일) | **DateRangePicker (범위)** | +| `datetime` | format 적용 | DateTimePicker | DateTimeRangePicker | +| `select` | 텍스트 그대로 | `` (전체/Y/N) | +| `textarea` | 말줄임 (...) | `