feat: V2/Unified 컴포넌트 설정 스키마 정비 및 레거시 컴포넌트 제거
- 레거시 컴포넌트를 제거하고, V2 및 Unified 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 통합 관리합니다. - V2 컴포넌트와 Unified 컴포넌트의 overrides 스키마를 정의하고, 기본값과의 병합 로직을 추가하였습니다. - 레이아웃 조회 시 V2 테이블을 우선적으로 조회하고, 없을 경우 V1 테이블을 조회하도록 개선하였습니다. - 관련된 테스트 계획 및 에러 처리 계획을 수립하여 안정성을 높였습니다.
This commit is contained in:
parent
7a7d06e785
commit
4fe512aeda
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,3 +1,45 @@
|
||||||
|
# 프로젝트: V2/Unified 컴포넌트 설정 스키마 정비
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
레거시 컴포넌트를 제거하고, V2/Unified 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
|
||||||
|
|
||||||
|
## 핵심 기능
|
||||||
|
1. [x] 레거시 컴포넌트 스키마 제거
|
||||||
|
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
||||||
|
3. [x] Unified 컴포넌트 overrides 스키마 정의 (9개)
|
||||||
|
4. [x] componentConfig.ts 한 파일에서 통합 관리
|
||||||
|
|
||||||
|
## 정의된 V2 컴포넌트 (18개)
|
||||||
|
- v2-table-list, v2-button-primary, v2-text-display
|
||||||
|
- v2-split-panel-layout, v2-section-card, v2-section-paper
|
||||||
|
- v2-divider-line, v2-repeat-container, v2-rack-structure
|
||||||
|
- v2-numbering-rule, v2-category-manager, v2-pivot-grid
|
||||||
|
- v2-location-swap-selector, v2-aggregation-widget
|
||||||
|
- v2-card-display, v2-table-search-widget, v2-tabs-widget
|
||||||
|
- v2-unified-repeater
|
||||||
|
|
||||||
|
## 정의된 Unified 컴포넌트 (9개)
|
||||||
|
- unified-input, unified-select, unified-date
|
||||||
|
- unified-list, unified-layout, unified-group
|
||||||
|
- unified-media, unified-biz, unified-hierarchy
|
||||||
|
|
||||||
|
## 테스트 계획
|
||||||
|
### 1단계: 기본 기능
|
||||||
|
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
|
||||||
|
- [x] Unified 컴포넌트 기본값과 스키마가 매칭됨
|
||||||
|
|
||||||
|
### 2단계: 에러 케이스
|
||||||
|
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
|
||||||
|
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
|
||||||
|
|
||||||
|
## 에러 처리 계획
|
||||||
|
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
|
||||||
|
- 기본값 누락 시 안전한 fallback 적용
|
||||||
|
|
||||||
|
## 진행 상태
|
||||||
|
- [x] 레거시 컴포넌트 제거 완료
|
||||||
|
- [x] V2/Unified 스키마 정의 완료
|
||||||
|
- [x] 한 파일 통합 관리 완료
|
||||||
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
|
|
|
||||||
|
|
@ -1586,6 +1586,7 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레이아웃 조회 (✅ Raw Query 전환 완료)
|
* 레이아웃 조회 (✅ Raw Query 전환 완료)
|
||||||
|
* V2 테이블 우선 조회 → 없으면 V1 테이블 조회
|
||||||
*/
|
*/
|
||||||
async getLayout(
|
async getLayout(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
@ -1610,6 +1611,76 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
|
||||||
|
let v2Layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 레이아웃 없으면 공통(*) 조회
|
||||||
|
if (!v2Layout && companyCode !== "*") {
|
||||||
|
v2Layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = '*'`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 레이아웃이 있으면 V2 형식으로 반환
|
||||||
|
if (v2Layout && v2Layout.layout_data) {
|
||||||
|
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||||
|
const layoutData = v2Layout.layout_data;
|
||||||
|
|
||||||
|
// V2 형식의 components를 LayoutData 형식으로 변환
|
||||||
|
const components = (layoutData.components || []).map((comp: any) => ({
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.overrides?.type || "component",
|
||||||
|
position: comp.position || { x: 0, y: 0, z: 1 },
|
||||||
|
size: comp.size || { width: 200, height: 100 },
|
||||||
|
componentUrl: comp.url,
|
||||||
|
componentType: comp.overrides?.type,
|
||||||
|
componentConfig: comp.overrides || {},
|
||||||
|
displayOrder: comp.displayOrder || 0,
|
||||||
|
...comp.overrides,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산
|
||||||
|
let screenResolution = layoutData.screenResolution;
|
||||||
|
if (!screenResolution && components.length > 0) {
|
||||||
|
let maxRight = 0;
|
||||||
|
let maxBottom = 0;
|
||||||
|
|
||||||
|
for (const comp of layoutData.components || []) {
|
||||||
|
const right = (comp.position?.x || 0) + (comp.size?.width || 200);
|
||||||
|
const bottom = (comp.position?.y || 0) + (comp.size?.height || 100);
|
||||||
|
maxRight = Math.max(maxRight, right);
|
||||||
|
maxBottom = Math.max(maxBottom, bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여백 100px 추가, 최소 1200x800 보장
|
||||||
|
screenResolution = {
|
||||||
|
width: Math.max(1200, maxRight + 100),
|
||||||
|
height: Math.max(800, maxBottom + 100),
|
||||||
|
};
|
||||||
|
console.log(`screenResolution 자동 계산:`, screenResolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
components,
|
||||||
|
gridSettings: layoutData.gridSettings || {
|
||||||
|
columns: 12,
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
snapToGrid: true,
|
||||||
|
showGrid: true,
|
||||||
|
},
|
||||||
|
screenResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
|
||||||
|
|
||||||
const layouts = await query<any>(
|
const layouts = await query<any>(
|
||||||
`SELECT * FROM screen_layouts
|
`SELECT * FROM screen_layouts
|
||||||
WHERE screen_id = $1
|
WHERE screen_id = $1
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 시스템 패키지 설치
|
# 시스템 패키지 설치 (curl: 헬스 체크용)
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# package.json 복사 및 의존성 설치 (개발 의존성 포함)
|
# package.json 복사 및 의존성 설치 (개발 의존성 포함)
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,5 @@ COPY . .
|
||||||
# 포트 노출
|
# 포트 노출
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# 개발 서버 시작 (Docker에서는 포트 3000 사용)
|
# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지)
|
||||||
CMD ["npm", "run", "dev", "--", "-p", "3000"]
|
CMD ["npm", "run", "dev:docker"]
|
||||||
|
|
@ -25,6 +25,7 @@ import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||||
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -148,10 +149,28 @@ function ScreenViewPage() {
|
||||||
const screenData = await screenApi.getScreen(screenId);
|
const screenData = await screenApi.getScreen(screenId);
|
||||||
setScreen(screenData);
|
setScreen(screenData);
|
||||||
|
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드 (V2 우선, Zod 기반 기본값 병합)
|
||||||
try {
|
try {
|
||||||
const layoutData = await screenApi.getLayout(screenId);
|
// V2 API 먼저 시도
|
||||||
setLayout(layoutData);
|
const v2Response = await screenApi.getLayoutV2(screenId);
|
||||||
|
|
||||||
|
if (v2Response && isValidV2Layout(v2Response)) {
|
||||||
|
// V2 레이아웃: Zod 기반 변환 (기본값 병합)
|
||||||
|
const convertedLayout = convertV2ToLegacy(v2Response);
|
||||||
|
if (convertedLayout) {
|
||||||
|
console.log("📦 V2 레이아웃 로드 (Zod 기반):", v2Response.components?.length || 0, "개 컴포넌트");
|
||||||
|
setLayout({
|
||||||
|
...convertedLayout,
|
||||||
|
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
|
||||||
|
} as LayoutData);
|
||||||
|
} else {
|
||||||
|
throw new Error("V2 레이아웃 변환 실패");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// V1 레이아웃 또는 빈 레이아웃
|
||||||
|
const layoutData = await screenApi.getLayout(screenId);
|
||||||
|
setLayout(layoutData);
|
||||||
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||||
setLayout({
|
setLayout({
|
||||||
|
|
@ -490,69 +509,8 @@ function ScreenViewPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
|
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
|
||||||
const autoLayoutComponents = (() => {
|
// ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화
|
||||||
// X 위치 기준으로 섹션 그룹화 (50px 오차 범위)
|
const autoLayoutComponents = regularComponents;
|
||||||
const X_THRESHOLD = 50;
|
|
||||||
const GAP = 16; // 컴포넌트 간 간격
|
|
||||||
|
|
||||||
// 컴포넌트를 X 섹션별로 그룹화
|
|
||||||
const sections: Map<number, typeof regularComponents> = new Map();
|
|
||||||
|
|
||||||
regularComponents.forEach((comp) => {
|
|
||||||
const x = comp.position.x;
|
|
||||||
let foundSection = false;
|
|
||||||
|
|
||||||
for (const [sectionX, components] of sections.entries()) {
|
|
||||||
if (Math.abs(x - sectionX) < X_THRESHOLD) {
|
|
||||||
components.push(comp);
|
|
||||||
foundSection = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundSection) {
|
|
||||||
sections.set(x, [comp]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 섹션 내에서 Y 위치 순으로 정렬 후 자동 배치
|
|
||||||
const adjustedMap = new Map<string, typeof regularComponents[0]>();
|
|
||||||
|
|
||||||
for (const [sectionX, components] of sections.entries()) {
|
|
||||||
// 섹션 내 2개 이상 컴포넌트가 있을 때만 자동 배치
|
|
||||||
if (components.length >= 2) {
|
|
||||||
// Y 위치 순으로 정렬
|
|
||||||
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
|
|
||||||
|
|
||||||
let currentY = sorted[0].position.y;
|
|
||||||
|
|
||||||
sorted.forEach((comp, index) => {
|
|
||||||
if (index === 0) {
|
|
||||||
adjustedMap.set(comp.id, comp);
|
|
||||||
} else {
|
|
||||||
// 이전 컴포넌트 아래로 배치
|
|
||||||
const prevComp = sorted[index - 1];
|
|
||||||
const prevAdjusted = adjustedMap.get(prevComp.id) || prevComp;
|
|
||||||
const prevBottom = prevAdjusted.position.y + (prevAdjusted.size?.height || 100);
|
|
||||||
const newY = prevBottom + GAP;
|
|
||||||
|
|
||||||
adjustedMap.set(comp.id, {
|
|
||||||
...comp,
|
|
||||||
position: {
|
|
||||||
...comp.position,
|
|
||||||
y: newY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 단일 컴포넌트는 그대로
|
|
||||||
components.forEach((comp) => adjustedMap.set(comp.id, comp));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return regularComponents.map((comp) => adjustedMap.get(comp.id) || comp);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
|
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
|
||||||
const adjustedComponents = autoLayoutComponents.map((component) => {
|
const adjustedComponents = autoLayoutComponents.map((component) => {
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
mainTableName: config.mainTableName,
|
mainTableName: config.mainTableName,
|
||||||
foreignKeyColumn: config.foreignKeyColumn,
|
foreignKeyColumn: config.foreignKeyColumn,
|
||||||
masterRecordId,
|
masterRecordId,
|
||||||
dataLength: data.length
|
dataLength: data.length,
|
||||||
});
|
};
|
||||||
|
console.log("UnifiedRepeater 저장 시작", saveInfo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 테이블 유효 컬럼 조회
|
// 테이블 유효 컬럼 조회
|
||||||
|
|
|
||||||
|
|
@ -882,7 +882,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
count: incomingData.length,
|
count: incomingData.length,
|
||||||
mode,
|
mode,
|
||||||
position: currentSplitPosition,
|
position: currentSplitPosition,
|
||||||
});
|
};
|
||||||
|
console.log("분할 패널 데이터 수신", receiveInfo);
|
||||||
|
|
||||||
await dataReceiver.receiveData(incomingData, {
|
await dataReceiver.receiveData(incomingData, {
|
||||||
targetComponentId: component.id,
|
targetComponentId: component.id,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 설정 공통 스키마 및 병합 유틸리티
|
* V2/Unified 컴포넌트 설정 스키마 및 병합 유틸리티
|
||||||
*
|
*
|
||||||
* 모든 컴포넌트가 공통으로 사용
|
* V2 컴포넌트와 Unified 컴포넌트의 overrides 스키마 및 기본값을 관리
|
||||||
* - 기본값: 각 컴포넌트의 defaultConfig에서 가져옴
|
* - 저장: component_url + overrides (차이값만)
|
||||||
* - 커스텀: DB custom_config에서 가져옴
|
* - 로드: 코드 기본값 + overrides 병합 (Zod)
|
||||||
* - 최종 설정 = 기본값 + 커스텀 (깊은 병합)
|
|
||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 공통 스키마 (모든 구조 허용)
|
// 공통 스키마 (모든 구조 허용)
|
||||||
// ============================================
|
// ============================================
|
||||||
export const customConfigSchema = z.record(z.any());
|
export const customConfigSchema = z.record(z.string(), z.any());
|
||||||
|
|
||||||
export type CustomConfig = z.infer<typeof customConfigSchema>;
|
export type CustomConfig = z.infer<typeof customConfigSchema>;
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 깊은 병합 함수
|
// 깊은 병합 함수
|
||||||
// ============================================
|
// ============================================
|
||||||
export function deepMerge<T extends Record<string, any>>(
|
export function deepMerge<T extends Record<string, any>>(target: T, source: Record<string, any>): T {
|
||||||
target: T,
|
|
||||||
source: Record<string, any>
|
|
||||||
): T {
|
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
|
|
||||||
for (const key of Object.keys(source)) {
|
for (const key of Object.keys(source)) {
|
||||||
|
|
@ -29,10 +25,7 @@ export function deepMerge<T extends Record<string, any>>(
|
||||||
const targetValue = result[key as keyof T];
|
const targetValue = result[key as keyof T];
|
||||||
|
|
||||||
// 둘 다 객체이고 배열이 아니면 깊은 병합
|
// 둘 다 객체이고 배열이 아니면 깊은 병합
|
||||||
if (
|
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
||||||
isPlainObject(sourceValue) &&
|
|
||||||
isPlainObject(targetValue)
|
|
||||||
) {
|
|
||||||
result[key as keyof T] = deepMerge(targetValue, sourceValue);
|
result[key as keyof T] = deepMerge(targetValue, sourceValue);
|
||||||
} else if (sourceValue !== undefined) {
|
} else if (sourceValue !== undefined) {
|
||||||
// source 값이 있으면 덮어쓰기
|
// source 값이 있으면 덮어쓰기
|
||||||
|
|
@ -57,7 +50,7 @@ function isPlainObject(value: unknown): value is Record<string, any> {
|
||||||
// ============================================
|
// ============================================
|
||||||
export function mergeComponentConfig(
|
export function mergeComponentConfig(
|
||||||
defaultConfig: Record<string, any>,
|
defaultConfig: Record<string, any>,
|
||||||
customConfig: Record<string, any> | null | undefined
|
customConfig: Record<string, any> | null | undefined,
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
if (!customConfig || Object.keys(customConfig).length === 0) {
|
if (!customConfig || Object.keys(customConfig).length === 0) {
|
||||||
return { ...defaultConfig };
|
return { ...defaultConfig };
|
||||||
|
|
@ -71,7 +64,7 @@ export function mergeComponentConfig(
|
||||||
// ============================================
|
// ============================================
|
||||||
export function extractCustomConfig(
|
export function extractCustomConfig(
|
||||||
fullConfig: Record<string, any>,
|
fullConfig: Record<string, any>,
|
||||||
defaultConfig: Record<string, any>
|
defaultConfig: Record<string, any>,
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
const customConfig: Record<string, any> = {};
|
const customConfig: Record<string, any> = {};
|
||||||
|
|
||||||
|
|
@ -132,7 +125,7 @@ export function getComponentUrl(componentType: string): string {
|
||||||
// 컴포넌트 타입 추출 함수 (URL에서)
|
// 컴포넌트 타입 추출 함수 (URL에서)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function getComponentTypeFromUrl(componentUrl: string): string {
|
export function getComponentTypeFromUrl(componentUrl: string): string {
|
||||||
// "@/lib/registry/components/split-panel-layout" → "split-panel-layout"
|
// "@/lib/registry/components/v2-table-list" → "v2-table-list"
|
||||||
const parts = componentUrl.split("/");
|
const parts = componentUrl.split("/");
|
||||||
return parts[parts.length - 1];
|
return parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
|
|
@ -152,84 +145,724 @@ export const componentV2Schema = z.object({
|
||||||
height: z.number().default(100),
|
height: z.number().default(100),
|
||||||
}),
|
}),
|
||||||
displayOrder: z.number().default(0),
|
displayOrder: z.number().default(0),
|
||||||
overrides: z.record(z.any()).default({}),
|
overrides: z.record(z.string(), z.any()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const layoutV2Schema = z.object({
|
export const layoutV2Schema = z.object({
|
||||||
version: z.string().default("2.0"),
|
version: z.string().default("2.0"),
|
||||||
components: z.array(componentV2Schema).default([]),
|
components: z.array(componentV2Schema).default([]),
|
||||||
updatedAt: z.string().optional(),
|
updatedAt: z.string().optional(),
|
||||||
|
screenResolution: z
|
||||||
|
.object({
|
||||||
|
width: z.number().default(1920),
|
||||||
|
height: z.number().default(1080),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
gridSettings: z.any().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ComponentV2 = z.infer<typeof componentV2Schema>;
|
export type ComponentV2 = z.infer<typeof componentV2Schema>;
|
||||||
export type LayoutV2 = z.infer<typeof layoutV2Schema>;
|
export type LayoutV2 = z.infer<typeof layoutV2Schema>;
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 컴포넌트별 기본값 레지스트리
|
// V2 컴포넌트 overrides 스키마 정의
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// v2-table-list
|
||||||
|
const v2TableListOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
displayMode: z.enum(["table", "card"]).default("table"),
|
||||||
|
showHeader: z.boolean().default(true),
|
||||||
|
showFooter: z.boolean().default(true),
|
||||||
|
height: z.string().default("auto"),
|
||||||
|
checkbox: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
multiple: z.boolean().default(true),
|
||||||
|
position: z.string().default("left"),
|
||||||
|
selectAll: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.default({ enabled: true, multiple: true, position: "left", selectAll: true }),
|
||||||
|
columns: z.array(z.any()).default([]),
|
||||||
|
autoWidth: z.boolean().default(true),
|
||||||
|
stickyHeader: z.boolean().default(false),
|
||||||
|
pagination: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
pageSize: z.number().default(20),
|
||||||
|
showSizeSelector: z.boolean().default(true),
|
||||||
|
showPageInfo: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.default({ enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }),
|
||||||
|
autoLoad: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-button-primary
|
||||||
|
const v2ButtonPrimaryOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
text: z.string().default("저장"),
|
||||||
|
actionType: z.string().default("button"),
|
||||||
|
variant: z.string().default("primary"),
|
||||||
|
action: z
|
||||||
|
.object({
|
||||||
|
type: z.string().default("save"),
|
||||||
|
successMessage: z.string().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-text-display
|
||||||
|
const v2TextDisplayOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
text: z.string().default("텍스트를 입력하세요"),
|
||||||
|
fontSize: z.string().default("14px"),
|
||||||
|
fontWeight: z.string().default("normal"),
|
||||||
|
color: z.string().default("#212121"),
|
||||||
|
textAlign: z.string().default("left"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-split-panel-layout
|
||||||
|
const v2SplitPanelLayoutOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
leftPanel: z
|
||||||
|
.object({
|
||||||
|
title: z.string().default("마스터"),
|
||||||
|
showSearch: z.boolean().default(false),
|
||||||
|
showAdd: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.default({ title: "마스터", showSearch: false, showAdd: false }),
|
||||||
|
rightPanel: z
|
||||||
|
.object({
|
||||||
|
title: z.string().default("디테일"),
|
||||||
|
showSearch: z.boolean().default(false),
|
||||||
|
showAdd: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.default({ title: "디테일", showSearch: false, showAdd: false }),
|
||||||
|
splitRatio: z.number().default(30),
|
||||||
|
resizable: z.boolean().default(true),
|
||||||
|
autoLoad: z.boolean().default(true),
|
||||||
|
syncSelection: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-section-card
|
||||||
|
const v2SectionCardOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().default("섹션 제목"),
|
||||||
|
description: z.string().default(""),
|
||||||
|
showHeader: z.boolean().default(true),
|
||||||
|
padding: z.string().default("md"),
|
||||||
|
backgroundColor: z.string().default("default"),
|
||||||
|
borderStyle: z.string().default("solid"),
|
||||||
|
collapsible: z.boolean().default(false),
|
||||||
|
defaultOpen: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-section-paper
|
||||||
|
const v2SectionPaperOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
backgroundColor: z.string().default("default"),
|
||||||
|
padding: z.string().default("md"),
|
||||||
|
roundedCorners: z.string().default("md"),
|
||||||
|
shadow: z.string().default("none"),
|
||||||
|
showBorder: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-divider-line
|
||||||
|
const v2DividerLineOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
placeholder: z.string().default("텍스트를 입력하세요"),
|
||||||
|
maxLength: z.number().default(255),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-repeat-container
|
||||||
|
const v2RepeatContainerOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
dataSourceType: z.string().default("manual"),
|
||||||
|
layout: z.string().default("vertical"),
|
||||||
|
gridColumns: z.number().default(2),
|
||||||
|
gap: z.string().default("16px"),
|
||||||
|
showBorder: z.boolean().default(true),
|
||||||
|
showShadow: z.boolean().default(false),
|
||||||
|
emptyMessage: z.string().default("데이터가 없습니다"),
|
||||||
|
usePaging: z.boolean().default(false),
|
||||||
|
pageSize: z.number().default(10),
|
||||||
|
clickable: z.boolean().default(false),
|
||||||
|
selectionMode: z.string().default("single"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-rack-structure
|
||||||
|
const v2RackStructureOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
showPreview: z.boolean().default(true),
|
||||||
|
showTemplate: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-numbering-rule
|
||||||
|
const v2NumberingRuleOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
showPreview: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-category-manager
|
||||||
|
const v2CategoryManagerOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
viewMode: z.string().default("tree"),
|
||||||
|
maxDepth: z.number().default(3),
|
||||||
|
showActions: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-pivot-grid
|
||||||
|
const v2PivotGridOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
fields: z.array(z.any()).default([]),
|
||||||
|
dataSource: z.any().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-location-swap-selector
|
||||||
|
const v2LocationSwapSelectorOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
dataSource: z
|
||||||
|
.object({
|
||||||
|
type: z.string().default("static"),
|
||||||
|
tableName: z.string().default(""),
|
||||||
|
valueField: z.string().default("location_code"),
|
||||||
|
labelField: z.string().default("location_name"),
|
||||||
|
})
|
||||||
|
.default({ type: "static", tableName: "", valueField: "location_code", labelField: "location_name" }),
|
||||||
|
departureField: z.string().default("departure"),
|
||||||
|
destinationField: z.string().default("destination"),
|
||||||
|
departureLabel: z.string().default("출발지"),
|
||||||
|
destinationLabel: z.string().default("도착지"),
|
||||||
|
showSwapButton: z.boolean().default(true),
|
||||||
|
variant: z.string().default("card"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-aggregation-widget
|
||||||
|
const v2AggregationWidgetOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
dataSourceType: z.string().default("table"),
|
||||||
|
items: z.array(z.any()).default([]),
|
||||||
|
filters: z.array(z.any()).default([]),
|
||||||
|
filterLogic: z.string().default("AND"),
|
||||||
|
layout: z.string().default("horizontal"),
|
||||||
|
showLabels: z.boolean().default(true),
|
||||||
|
showIcons: z.boolean().default(true),
|
||||||
|
gap: z.string().default("16px"),
|
||||||
|
autoRefresh: z.boolean().default(false),
|
||||||
|
refreshOnFormChange: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-card-display
|
||||||
|
const v2CardDisplayOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
cardsPerRow: z.number().default(3),
|
||||||
|
cardSpacing: z.number().default(16),
|
||||||
|
cardStyle: z
|
||||||
|
.object({
|
||||||
|
showTitle: z.boolean().default(true),
|
||||||
|
showSubtitle: z.boolean().default(true),
|
||||||
|
showDescription: z.boolean().default(true),
|
||||||
|
showImage: z.boolean().default(false),
|
||||||
|
showActions: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.default({ showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }),
|
||||||
|
columnMapping: z.record(z.string(), z.any()).default({}),
|
||||||
|
dataSource: z.string().default("table"),
|
||||||
|
staticData: z.array(z.any()).default([]),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-table-search-widget
|
||||||
|
const v2TableSearchWidgetOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().default("테이블 검색"),
|
||||||
|
autoSelectFirstTable: z.boolean().default(true),
|
||||||
|
showTableSelector: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-tabs-widget
|
||||||
|
const v2TabsWidgetOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
tabs: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
order: z.number().default(0),
|
||||||
|
disabled: z.boolean().default(false),
|
||||||
|
components: z.array(z.any()).default([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([
|
||||||
|
{ id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [] },
|
||||||
|
{ id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [] },
|
||||||
|
]),
|
||||||
|
defaultTab: z.string().default("tab-1"),
|
||||||
|
orientation: z.enum(["horizontal", "vertical"]).default("horizontal"),
|
||||||
|
variant: z.string().default("default"),
|
||||||
|
allowCloseable: z.boolean().default(false),
|
||||||
|
persistSelection: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2-unified-repeater
|
||||||
|
const v2UnifiedRepeaterOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
renderMode: z.enum(["inline", "modal", "button", "mixed"]).default("inline"),
|
||||||
|
dataSource: z
|
||||||
|
.object({
|
||||||
|
tableName: z.string().default(""),
|
||||||
|
foreignKey: z.string().default(""),
|
||||||
|
referenceKey: z.string().default(""),
|
||||||
|
})
|
||||||
|
.default({ tableName: "", foreignKey: "", referenceKey: "" }),
|
||||||
|
columns: z.array(z.any()).default([]),
|
||||||
|
modal: z.object({ size: z.string().default("md") }).default({ size: "md" }),
|
||||||
|
button: z
|
||||||
|
.object({
|
||||||
|
sourceType: z.string().default("manual"),
|
||||||
|
manualButtons: z.array(z.any()).default([]),
|
||||||
|
layout: z.string().default("horizontal"),
|
||||||
|
style: z.string().default("outline"),
|
||||||
|
})
|
||||||
|
.default({ sourceType: "manual", manualButtons: [], layout: "horizontal", style: "outline" }),
|
||||||
|
features: z
|
||||||
|
.object({
|
||||||
|
showAddButton: z.boolean().default(true),
|
||||||
|
showDeleteButton: z.boolean().default(true),
|
||||||
|
inlineEdit: z.boolean().default(false),
|
||||||
|
dragSort: z.boolean().default(false),
|
||||||
|
showRowNumber: z.boolean().default(false),
|
||||||
|
selectable: z.boolean().default(false),
|
||||||
|
multiSelect: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
showAddButton: true,
|
||||||
|
showDeleteButton: true,
|
||||||
|
inlineEdit: false,
|
||||||
|
dragSort: false,
|
||||||
|
showRowNumber: false,
|
||||||
|
selectable: false,
|
||||||
|
multiSelect: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Unified 컴포넌트 overrides 스키마 정의
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// unified-input
|
||||||
|
const unifiedInputOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
inputType: z.string().default("text"),
|
||||||
|
format: z.string().default("none"),
|
||||||
|
placeholder: z.string().default(""),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-select
|
||||||
|
const unifiedSelectOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
mode: z.string().default("dropdown"),
|
||||||
|
source: z.string().default("static"),
|
||||||
|
options: z.array(z.any()).default([]),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-date
|
||||||
|
const unifiedDateOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
dateType: z.string().default("date"),
|
||||||
|
format: z.string().default("YYYY-MM-DD"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-list
|
||||||
|
const unifiedListOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
viewMode: z.string().default("table"),
|
||||||
|
source: z.string().default("static"),
|
||||||
|
columns: z.array(z.any()).default([]),
|
||||||
|
pagination: z.boolean().default(true),
|
||||||
|
sortable: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-layout
|
||||||
|
const unifiedLayoutOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
layoutType: z.string().default("grid"),
|
||||||
|
columns: z.number().default(2),
|
||||||
|
gap: z.string().default("16"),
|
||||||
|
use12Column: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-group
|
||||||
|
const unifiedGroupOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
groupType: z.string().default("section"),
|
||||||
|
title: z.string().default(""),
|
||||||
|
collapsible: z.boolean().default(false),
|
||||||
|
defaultOpen: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-media
|
||||||
|
const unifiedMediaOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
mediaType: z.string().default("image"),
|
||||||
|
multiple: z.boolean().default(false),
|
||||||
|
preview: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-biz
|
||||||
|
const unifiedBizOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
bizType: z.string().default("flow"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-hierarchy
|
||||||
|
const unifiedHierarchyOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
hierarchyType: z.string().default("tree"),
|
||||||
|
viewMode: z.string().default("tree"),
|
||||||
|
dataSource: z.string().default("static"),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// unified-repeater
|
||||||
|
const unifiedRepeaterOverridesSchema = z
|
||||||
|
.object({
|
||||||
|
renderMode: z.enum(["inline", "modal"]).default("inline"),
|
||||||
|
mainTableName: z.string().optional(),
|
||||||
|
useCustomTable: z.boolean().default(false),
|
||||||
|
foreignKeyColumn: z.string().optional(),
|
||||||
|
foreignKeySourceColumn: z.string().optional(),
|
||||||
|
dataSource: z
|
||||||
|
.object({
|
||||||
|
tableName: z.string().optional(),
|
||||||
|
sourceTable: z.string().optional(),
|
||||||
|
foreignKey: z.string().optional(),
|
||||||
|
referenceKey: z.string().optional(),
|
||||||
|
displayColumn: z.string().optional(),
|
||||||
|
})
|
||||||
|
.default({}),
|
||||||
|
columns: z.array(z.any()).default([]),
|
||||||
|
columnMappings: z.array(z.any()).default([]),
|
||||||
|
calculationRules: z.array(z.any()).default([]),
|
||||||
|
modal: z
|
||||||
|
.object({
|
||||||
|
size: z.enum(["sm", "md", "lg", "xl", "full"]).default("lg"),
|
||||||
|
title: z.string().optional(),
|
||||||
|
buttonText: z.string().optional(),
|
||||||
|
sourceDisplayColumns: z.array(z.any()).default([]),
|
||||||
|
searchFields: z.array(z.string()).default([]),
|
||||||
|
})
|
||||||
|
.default({ size: "lg", sourceDisplayColumns: [], searchFields: [] }),
|
||||||
|
features: z
|
||||||
|
.object({
|
||||||
|
showAddButton: z.boolean().default(true),
|
||||||
|
showDeleteButton: z.boolean().default(true),
|
||||||
|
inlineEdit: z.boolean().default(true),
|
||||||
|
dragSort: z.boolean().default(false),
|
||||||
|
showRowNumber: z.boolean().default(false),
|
||||||
|
selectable: z.boolean().default(false),
|
||||||
|
multiSelect: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
showAddButton: true,
|
||||||
|
showDeleteButton: true,
|
||||||
|
inlineEdit: true,
|
||||||
|
dragSort: false,
|
||||||
|
showRowNumber: false,
|
||||||
|
selectable: false,
|
||||||
|
multiSelect: true,
|
||||||
|
}),
|
||||||
|
style: z
|
||||||
|
.object({
|
||||||
|
maxHeight: z.string().optional(),
|
||||||
|
minHeight: z.string().optional(),
|
||||||
|
borderless: z.boolean().default(false),
|
||||||
|
compact: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 컴포넌트별 overrides 스키마 레지스트리
|
||||||
|
// ============================================
|
||||||
|
const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string, any>>> = {
|
||||||
|
// V2 컴포넌트 (16개)
|
||||||
|
"v2-table-list": v2TableListOverridesSchema,
|
||||||
|
"v2-button-primary": v2ButtonPrimaryOverridesSchema,
|
||||||
|
"v2-text-display": v2TextDisplayOverridesSchema,
|
||||||
|
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
|
||||||
|
"v2-section-card": v2SectionCardOverridesSchema,
|
||||||
|
"v2-section-paper": v2SectionPaperOverridesSchema,
|
||||||
|
"v2-divider-line": v2DividerLineOverridesSchema,
|
||||||
|
"v2-repeat-container": v2RepeatContainerOverridesSchema,
|
||||||
|
"v2-rack-structure": v2RackStructureOverridesSchema,
|
||||||
|
"v2-numbering-rule": v2NumberingRuleOverridesSchema,
|
||||||
|
"v2-category-manager": v2CategoryManagerOverridesSchema,
|
||||||
|
"v2-pivot-grid": v2PivotGridOverridesSchema,
|
||||||
|
"v2-location-swap-selector": v2LocationSwapSelectorOverridesSchema,
|
||||||
|
"v2-aggregation-widget": v2AggregationWidgetOverridesSchema,
|
||||||
|
"v2-card-display": v2CardDisplayOverridesSchema,
|
||||||
|
"v2-table-search-widget": v2TableSearchWidgetOverridesSchema,
|
||||||
|
"v2-tabs-widget": v2TabsWidgetOverridesSchema,
|
||||||
|
"v2-unified-repeater": v2UnifiedRepeaterOverridesSchema,
|
||||||
|
|
||||||
|
// Unified 컴포넌트 (9개)
|
||||||
|
"unified-input": unifiedInputOverridesSchema,
|
||||||
|
"unified-select": unifiedSelectOverridesSchema,
|
||||||
|
"unified-date": unifiedDateOverridesSchema,
|
||||||
|
"unified-list": unifiedListOverridesSchema,
|
||||||
|
"unified-layout": unifiedLayoutOverridesSchema,
|
||||||
|
"unified-group": unifiedGroupOverridesSchema,
|
||||||
|
"unified-media": unifiedMediaOverridesSchema,
|
||||||
|
"unified-biz": unifiedBizOverridesSchema,
|
||||||
|
"unified-hierarchy": unifiedHierarchyOverridesSchema,
|
||||||
|
"unified-repeater": unifiedRepeaterOverridesSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 컴포넌트별 기본값 레지스트리 (fallback용)
|
||||||
// ============================================
|
// ============================================
|
||||||
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||||
"table-list": {
|
// V2 컴포넌트
|
||||||
pagination: true,
|
"v2-table-list": {
|
||||||
pageSize: 20,
|
displayMode: "table",
|
||||||
selectable: true,
|
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
|
showFooter: true,
|
||||||
|
height: "auto",
|
||||||
|
checkbox: { enabled: true, multiple: true, position: "left", selectAll: true },
|
||||||
|
columns: [],
|
||||||
|
autoWidth: true,
|
||||||
|
stickyHeader: false,
|
||||||
|
pagination: { enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true },
|
||||||
|
autoLoad: true,
|
||||||
},
|
},
|
||||||
"button-primary": {
|
"v2-button-primary": {
|
||||||
label: "버튼",
|
text: "저장",
|
||||||
|
actionType: "button",
|
||||||
|
variant: "primary",
|
||||||
|
action: { type: "save", successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다." },
|
||||||
|
},
|
||||||
|
"v2-text-display": {
|
||||||
|
text: "텍스트를 입력하세요",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "normal",
|
||||||
|
color: "#212121",
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
"v2-split-panel-layout": {
|
||||||
|
leftPanel: { title: "마스터", showSearch: false, showAdd: false },
|
||||||
|
rightPanel: { title: "디테일", showSearch: false, showAdd: false },
|
||||||
|
splitRatio: 30,
|
||||||
|
resizable: true,
|
||||||
|
autoLoad: true,
|
||||||
|
syncSelection: true,
|
||||||
|
},
|
||||||
|
"v2-section-card": {
|
||||||
|
title: "섹션 제목",
|
||||||
|
description: "",
|
||||||
|
showHeader: true,
|
||||||
|
padding: "md",
|
||||||
|
backgroundColor: "default",
|
||||||
|
borderStyle: "solid",
|
||||||
|
collapsible: false,
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
|
"v2-section-paper": {
|
||||||
|
backgroundColor: "default",
|
||||||
|
padding: "md",
|
||||||
|
roundedCorners: "md",
|
||||||
|
shadow: "none",
|
||||||
|
showBorder: false,
|
||||||
|
},
|
||||||
|
"v2-divider-line": {
|
||||||
|
placeholder: "텍스트를 입력하세요",
|
||||||
|
maxLength: 255,
|
||||||
|
},
|
||||||
|
"v2-repeat-container": {
|
||||||
|
dataSourceType: "manual",
|
||||||
|
layout: "vertical",
|
||||||
|
gridColumns: 2,
|
||||||
|
gap: "16px",
|
||||||
|
showBorder: true,
|
||||||
|
showShadow: false,
|
||||||
|
emptyMessage: "데이터가 없습니다",
|
||||||
|
usePaging: false,
|
||||||
|
pageSize: 10,
|
||||||
|
clickable: false,
|
||||||
|
selectionMode: "single",
|
||||||
|
},
|
||||||
|
"v2-rack-structure": {
|
||||||
|
showPreview: true,
|
||||||
|
showTemplate: true,
|
||||||
|
},
|
||||||
|
"v2-numbering-rule": {
|
||||||
|
showPreview: true,
|
||||||
|
},
|
||||||
|
"v2-category-manager": {
|
||||||
|
viewMode: "tree",
|
||||||
|
maxDepth: 3,
|
||||||
|
showActions: true,
|
||||||
|
},
|
||||||
|
"v2-pivot-grid": {
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
"v2-location-swap-selector": {
|
||||||
|
dataSource: { type: "static", tableName: "", valueField: "location_code", labelField: "location_name" },
|
||||||
|
departureField: "departure",
|
||||||
|
destinationField: "destination",
|
||||||
|
departureLabel: "출발지",
|
||||||
|
destinationLabel: "도착지",
|
||||||
|
showSwapButton: true,
|
||||||
|
variant: "card",
|
||||||
|
},
|
||||||
|
"v2-aggregation-widget": {
|
||||||
|
dataSourceType: "table",
|
||||||
|
items: [],
|
||||||
|
filters: [],
|
||||||
|
filterLogic: "AND",
|
||||||
|
layout: "horizontal",
|
||||||
|
showLabels: true,
|
||||||
|
showIcons: true,
|
||||||
|
gap: "16px",
|
||||||
|
autoRefresh: false,
|
||||||
|
refreshOnFormChange: true,
|
||||||
|
},
|
||||||
|
"v2-card-display": {
|
||||||
|
cardsPerRow: 3,
|
||||||
|
cardSpacing: 16,
|
||||||
|
cardStyle: { showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true },
|
||||||
|
columnMapping: {},
|
||||||
|
dataSource: "table",
|
||||||
|
staticData: [],
|
||||||
|
},
|
||||||
|
"v2-table-search-widget": {
|
||||||
|
title: "테이블 검색",
|
||||||
|
autoSelectFirstTable: true,
|
||||||
|
showTableSelector: true,
|
||||||
|
},
|
||||||
|
"v2-tabs-widget": {
|
||||||
|
tabs: [
|
||||||
|
{ id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [] },
|
||||||
|
{ id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [] },
|
||||||
|
],
|
||||||
|
defaultTab: "tab-1",
|
||||||
|
orientation: "horizontal",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
allowCloseable: false,
|
||||||
|
persistSelection: false,
|
||||||
},
|
},
|
||||||
"text-input": {
|
"v2-unified-repeater": {
|
||||||
|
renderMode: "inline",
|
||||||
|
dataSource: { tableName: "", foreignKey: "", referenceKey: "" },
|
||||||
|
columns: [],
|
||||||
|
modal: { size: "md" },
|
||||||
|
button: { sourceType: "manual", manualButtons: [], layout: "horizontal", style: "outline" },
|
||||||
|
features: {
|
||||||
|
showAddButton: true,
|
||||||
|
showDeleteButton: true,
|
||||||
|
inlineEdit: false,
|
||||||
|
dragSort: false,
|
||||||
|
showRowNumber: false,
|
||||||
|
selectable: false,
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unified 컴포넌트
|
||||||
|
"unified-input": {
|
||||||
|
inputType: "text",
|
||||||
|
format: "none",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
multiline: false,
|
|
||||||
},
|
},
|
||||||
"select-basic": {
|
"unified-select": {
|
||||||
placeholder: "선택하세요",
|
mode: "dropdown",
|
||||||
|
source: "static",
|
||||||
options: [],
|
options: [],
|
||||||
},
|
},
|
||||||
"date-input": {
|
"unified-date": {
|
||||||
|
dateType: "date",
|
||||||
format: "YYYY-MM-DD",
|
format: "YYYY-MM-DD",
|
||||||
},
|
},
|
||||||
"split-panel-layout": {
|
"unified-list": {
|
||||||
splitRatio: 50,
|
viewMode: "table",
|
||||||
direction: "horizontal",
|
source: "static",
|
||||||
resizable: true,
|
columns: [],
|
||||||
|
pagination: true,
|
||||||
|
sortable: true,
|
||||||
},
|
},
|
||||||
"tabs-widget": {
|
"unified-layout": {
|
||||||
tabs: [],
|
layoutType: "grid",
|
||||||
defaultTab: 0,
|
columns: 2,
|
||||||
|
gap: "16",
|
||||||
|
use12Column: true,
|
||||||
},
|
},
|
||||||
"card-display": {
|
"unified-group": {
|
||||||
|
groupType: "section",
|
||||||
title: "",
|
title: "",
|
||||||
bordered: true,
|
collapsible: false,
|
||||||
|
defaultOpen: true,
|
||||||
},
|
},
|
||||||
"flow-widget": {
|
"unified-media": {
|
||||||
flowId: null,
|
mediaType: "image",
|
||||||
},
|
|
||||||
"category-management": {
|
|
||||||
categoryType: "",
|
|
||||||
},
|
|
||||||
"pivot-table": {
|
|
||||||
rows: [],
|
|
||||||
columns: [],
|
|
||||||
values: [],
|
|
||||||
},
|
|
||||||
"unified-grid": {
|
|
||||||
columns: [],
|
|
||||||
},
|
|
||||||
"checkbox-basic": {
|
|
||||||
label: "",
|
|
||||||
defaultChecked: false,
|
|
||||||
},
|
|
||||||
"radio-basic": {
|
|
||||||
options: [],
|
|
||||||
},
|
|
||||||
"file-upload": {
|
|
||||||
accept: "*",
|
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
preview: true,
|
||||||
},
|
},
|
||||||
"repeat-container": {
|
"unified-biz": {
|
||||||
children: [],
|
bizType: "flow",
|
||||||
|
},
|
||||||
|
"unified-hierarchy": {
|
||||||
|
hierarchyType: "tree",
|
||||||
|
viewMode: "tree",
|
||||||
|
dataSource: "static",
|
||||||
|
},
|
||||||
|
"unified-repeater": {
|
||||||
|
renderMode: "inline",
|
||||||
|
useCustomTable: false,
|
||||||
|
dataSource: {},
|
||||||
|
columns: [],
|
||||||
|
columnMappings: [],
|
||||||
|
calculationRules: [],
|
||||||
|
modal: {
|
||||||
|
size: "lg",
|
||||||
|
sourceDisplayColumns: [],
|
||||||
|
searchFields: [],
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
showAddButton: true,
|
||||||
|
showDeleteButton: true,
|
||||||
|
inlineEdit: true,
|
||||||
|
dragSort: false,
|
||||||
|
showRowNumber: false,
|
||||||
|
selectable: false,
|
||||||
|
multiSelect: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -237,6 +870,11 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||||
// 컴포넌트 기본값 조회
|
// 컴포넌트 기본값 조회
|
||||||
// ============================================
|
// ============================================
|
||||||
export function getComponentDefaults(componentType: string): Record<string, any> {
|
export function getComponentDefaults(componentType: string): Record<string, any> {
|
||||||
|
const schema = componentOverridesSchemaRegistry[componentType];
|
||||||
|
if (schema) {
|
||||||
|
return schema.parse({});
|
||||||
|
}
|
||||||
|
|
||||||
return componentDefaultsRegistry[componentType] || {};
|
return componentDefaultsRegistry[componentType] || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,12 +886,41 @@ export function getDefaultsByUrl(url: string): Record<string, any> {
|
||||||
return getComponentDefaults(componentType);
|
return getComponentDefaults(componentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// overrides 스키마 파싱 (유효성 검사)
|
||||||
|
// ============================================
|
||||||
|
export function parseOverridesByUrl(
|
||||||
|
url: string,
|
||||||
|
overrides: Record<string, any> | null | undefined,
|
||||||
|
options?: { applyDefaults?: boolean },
|
||||||
|
): Record<string, any> {
|
||||||
|
const componentType = getComponentTypeFromUrl(url);
|
||||||
|
const schema = componentOverridesSchemaRegistry[componentType];
|
||||||
|
const applyDefaults = options?.applyDefaults ?? false;
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
return overrides || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(overrides || {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.warn("V2 overrides 스키마 검증 실패", {
|
||||||
|
componentType,
|
||||||
|
errors: parsed.error.issues,
|
||||||
|
});
|
||||||
|
return overrides || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyDefaults ? parsed.data : overrides || {};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// V2 컴포넌트 로드 (기본값 + overrides 병합)
|
// V2 컴포넌트 로드 (기본값 + overrides 병합)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record<string, any> } {
|
export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record<string, any> } {
|
||||||
const defaults = getDefaultsByUrl(component.url);
|
const defaults = getDefaultsByUrl(component.url);
|
||||||
const config = mergeComponentConfig(defaults, component.overrides);
|
const overrides = parseOverridesByUrl(component.url, component.overrides);
|
||||||
|
const config = mergeComponentConfig(defaults, overrides);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...component,
|
...component,
|
||||||
|
|
@ -264,13 +931,14 @@ export function loadComponentV2(component: ComponentV2): ComponentV2 & { config:
|
||||||
// ============================================
|
// ============================================
|
||||||
// V2 컴포넌트 저장 (차이값 추출)
|
// V2 컴포넌트 저장 (차이값 추출)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function saveComponentV2(
|
export function saveComponentV2(component: ComponentV2 & { config?: Record<string, any> }): ComponentV2 {
|
||||||
component: ComponentV2 & { config?: Record<string, any> }
|
|
||||||
): ComponentV2 {
|
|
||||||
const defaults = getDefaultsByUrl(component.url);
|
const defaults = getDefaultsByUrl(component.url);
|
||||||
const overrides = component.config
|
const normalizedConfig = component.config
|
||||||
? extractCustomConfig(component.config, defaults)
|
? parseOverridesByUrl(component.url, component.config, { applyDefaults: true })
|
||||||
: component.overrides;
|
: undefined;
|
||||||
|
const normalizedOverrides = normalizedConfig
|
||||||
|
? extractCustomConfig(normalizedConfig, defaults)
|
||||||
|
: parseOverridesByUrl(component.url, component.overrides);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: component.id,
|
id: component.id,
|
||||||
|
|
@ -278,14 +946,16 @@ export function saveComponentV2(
|
||||||
position: component.position,
|
position: component.position,
|
||||||
size: component.size,
|
size: component.size,
|
||||||
displayOrder: component.displayOrder,
|
displayOrder: component.displayOrder,
|
||||||
overrides,
|
overrides: normalizedOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
|
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
|
export function loadLayoutV2(
|
||||||
|
layoutData: any,
|
||||||
|
): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
|
||||||
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
|
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -297,9 +967,7 @@ export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array<Co
|
||||||
// ============================================
|
// ============================================
|
||||||
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
|
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function saveLayoutV2(
|
export function saveLayoutV2(components: Array<ComponentV2 & { config?: Record<string, any> }>): LayoutV2 {
|
||||||
components: Array<ComponentV2 & { config?: Record<string, any> }>
|
|
||||||
): LayoutV2 {
|
|
||||||
return {
|
return {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
components: components.map(saveComponentV2),
|
components: components.map(saveComponentV2),
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/**
|
|
||||||
* button-primary 컴포넌트 Zod 스키마 및 기본값
|
|
||||||
*/
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// 버튼 액션 스키마
|
|
||||||
export const buttonActionSchema = z.object({
|
|
||||||
type: z.string().default("save"),
|
|
||||||
targetScreenId: z.number().optional(),
|
|
||||||
successMessage: z.string().optional(),
|
|
||||||
errorMessage: z.string().optional(),
|
|
||||||
modalSize: z.string().optional(),
|
|
||||||
modalTitle: z.string().optional(),
|
|
||||||
modalDescription: z.string().optional(),
|
|
||||||
modalTitleBlocks: z.array(z.any()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// button-primary 설정 스키마
|
|
||||||
export const buttonPrimaryConfigSchema = z.object({
|
|
||||||
type: z.literal("button-primary").default("button-primary"),
|
|
||||||
text: z.string().default("저장"),
|
|
||||||
actionType: z.enum(["button", "submit", "reset"]).default("button"),
|
|
||||||
variant: z.enum(["primary", "secondary", "danger", "outline", "destructive"]).default("primary"),
|
|
||||||
webType: z.literal("button").default("button"),
|
|
||||||
action: buttonActionSchema.optional(),
|
|
||||||
// 추가 속성들
|
|
||||||
label: z.string().optional(),
|
|
||||||
langKey: z.string().optional(),
|
|
||||||
langKeyId: z.number().optional(),
|
|
||||||
size: z.string().optional(),
|
|
||||||
backgroundColor: z.string().optional(),
|
|
||||||
textColor: z.string().optional(),
|
|
||||||
borderRadius: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonPrimaryConfig = z.infer<typeof buttonPrimaryConfigSchema>;
|
|
||||||
|
|
||||||
// 기본값 (스키마에서 자동 생성)
|
|
||||||
export const buttonPrimaryDefaults: ButtonPrimaryConfig = buttonPrimaryConfigSchema.parse({});
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
|
|
||||||
return {
|
return {
|
||||||
components,
|
components,
|
||||||
gridSettings: {
|
gridSettings: v2Layout.gridSettings || {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: "#d1d5db",
|
color: "#d1d5db",
|
||||||
|
|
@ -75,7 +75,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
gap: 16,
|
gap: 16,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
screenResolution: {
|
screenResolution: v2Layout.screenResolution || {
|
||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 9771",
|
"dev": "next dev --turbopack -p 9771",
|
||||||
|
"dev:docker": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue