feat: V2/Unified 컴포넌트 설정 스키마 정비 및 레거시 컴포넌트 제거
- 레거시 컴포넌트를 제거하고, V2 및 Unified 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 통합 관리합니다. - V2 컴포넌트와 Unified 컴포넌트의 overrides 스키마를 정의하고, 기본값과의 병합 로직을 추가하였습니다. - 레이아웃 조회 시 V2 테이블을 우선적으로 조회하고, 없을 경우 V1 테이블을 조회하도록 개선하였습니다. - 관련된 테스트 계획 및 에러 처리 계획을 수립하여 안정성을 높였습니다.
This commit is contained in:
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 구조 개편 후)
|
||||
|
||||
## 개요
|
||||
|
||||
@@ -1586,6 +1586,7 @@ export class ScreenManagementService {
|
||||
|
||||
/**
|
||||
* 레이아웃 조회 (✅ Raw Query 전환 완료)
|
||||
* V2 테이블 우선 조회 → 없으면 V1 테이블 조회
|
||||
*/
|
||||
async getLayout(
|
||||
screenId: number,
|
||||
@@ -1610,6 +1611,76 @@ export class ScreenManagementService {
|
||||
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>(
|
||||
`SELECT * FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
|
||||
@@ -3,9 +3,9 @@ FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 설치
|
||||
# 시스템 패키지 설치 (curl: 헬스 체크용)
|
||||
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/*
|
||||
|
||||
# package.json 복사 및 의존성 설치 (개발 의존성 포함)
|
||||
|
||||
@@ -16,5 +16,5 @@ COPY . .
|
||||
# 포트 노출
|
||||
EXPOSE 3000
|
||||
|
||||
# 개발 서버 시작 (Docker에서는 포트 3000 사용)
|
||||
CMD ["npm", "run", "dev", "--", "-p", "3000"]
|
||||
# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지)
|
||||
CMD ["npm", "run", "dev:docker"]
|
||||
@@ -25,6 +25,7 @@ import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
@@ -148,10 +149,28 @@ function ScreenViewPage() {
|
||||
const screenData = await screenApi.getScreen(screenId);
|
||||
setScreen(screenData);
|
||||
|
||||
// 레이아웃 로드
|
||||
// 레이아웃 로드 (V2 우선, Zod 기반 기본값 병합)
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(screenId);
|
||||
setLayout(layoutData);
|
||||
// V2 API 먼저 시도
|
||||
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) {
|
||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||
setLayout({
|
||||
@@ -490,69 +509,8 @@ function ScreenViewPage() {
|
||||
);
|
||||
|
||||
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
|
||||
const autoLayoutComponents = (() => {
|
||||
// X 위치 기준으로 섹션 그룹화 (50px 오차 범위)
|
||||
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);
|
||||
})();
|
||||
// ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화
|
||||
const autoLayoutComponents = regularComponents;
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
|
||||
const adjustedComponents = autoLayoutComponents.map((component) => {
|
||||
|
||||
@@ -130,8 +130,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||
mainTableName: config.mainTableName,
|
||||
foreignKeyColumn: config.foreignKeyColumn,
|
||||
masterRecordId,
|
||||
dataLength: data.length
|
||||
});
|
||||
dataLength: data.length,
|
||||
};
|
||||
console.log("UnifiedRepeater 저장 시작", saveInfo);
|
||||
|
||||
try {
|
||||
// 테이블 유효 컬럼 조회
|
||||
|
||||
@@ -882,7 +882,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
count: incomingData.length,
|
||||
mode,
|
||||
position: currentSplitPosition,
|
||||
});
|
||||
};
|
||||
console.log("분할 패널 데이터 수신", receiveInfo);
|
||||
|
||||
await dataReceiver.receiveData(incomingData, {
|
||||
targetComponentId: component.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
components,
|
||||
gridSettings: {
|
||||
gridSettings: v2Layout.gridSettings || {
|
||||
enabled: true,
|
||||
size: 20,
|
||||
color: "#d1d5db",
|
||||
@@ -75,7 +75,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
},
|
||||
screenResolution: {
|
||||
screenResolution: v2Layout.screenResolution || {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 9771",
|
||||
"dev:docker": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
|
||||
"start": "next start",
|
||||
|
||||
Reference in New Issue
Block a user