Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal
This commit is contained in:
@@ -5,9 +5,13 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 인증된 사용자 타입
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
|
||||
@@ -308,18 +308,42 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상)
|
||||
// 0. 삭제할 그룹의 company_code 확인
|
||||
const targetGroupResult = await client.query(
|
||||
`SELECT company_code FROM screen_groups WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (targetGroupResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
const targetCompanyCode = targetGroupResult.rows[0].company_code;
|
||||
|
||||
// 권한 체크: 최고관리자가 아닌 경우 자신의 회사 그룹만 삭제 가능
|
||||
if (companyCode !== "*" && targetCompanyCode !== companyCode) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(403).json({ success: false, message: "권한이 없습니다." });
|
||||
}
|
||||
|
||||
// 1. 삭제할 그룹과 하위 그룹 ID 수집 (같은 회사만 - CASCADE 삭제 대상)
|
||||
const childGroupsResult = await client.query(`
|
||||
WITH RECURSIVE child_groups AS (
|
||||
SELECT id FROM screen_groups WHERE id = $1
|
||||
SELECT id, company_code FROM screen_groups WHERE id = $1 AND company_code = $2
|
||||
UNION ALL
|
||||
SELECT sg.id FROM screen_groups sg
|
||||
JOIN child_groups cg ON sg.parent_group_id = cg.id
|
||||
SELECT sg.id, sg.company_code FROM screen_groups sg
|
||||
JOIN child_groups cg ON sg.parent_group_id = cg.id AND sg.company_code = cg.company_code
|
||||
)
|
||||
SELECT id FROM child_groups
|
||||
`, [id]);
|
||||
`, [id, targetCompanyCode]);
|
||||
const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id);
|
||||
|
||||
logger.info("화면 그룹 삭제 대상", {
|
||||
companyCode,
|
||||
targetCompanyCode,
|
||||
groupId: id,
|
||||
childGroupIds: groupIdsToDelete
|
||||
});
|
||||
|
||||
// 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
|
||||
if (groupIdsToDelete.length > 0) {
|
||||
await client.query(`
|
||||
@@ -329,18 +353,11 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
||||
`, [groupIdsToDelete]);
|
||||
}
|
||||
|
||||
// 3. screen_groups 삭제
|
||||
let query = `DELETE FROM screen_groups WHERE id = $1`;
|
||||
const params: any[] = [id];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
query += ` AND company_code = $2`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
query += " RETURNING id";
|
||||
|
||||
const result = await client.query(query, params);
|
||||
// 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제)
|
||||
const result = await client.query(
|
||||
`DELETE FROM screen_groups WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -349,7 +366,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
|
||||
logger.info("화면 그룹 삭제 완료", { companyCode, targetCompanyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
|
||||
|
||||
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -961,6 +961,16 @@ export class MenuCopyService {
|
||||
const menus = await this.collectMenuTree(sourceMenuObjid, client);
|
||||
const sourceCompanyCode = menus[0].company_code!;
|
||||
|
||||
// 같은 회사로 복제하는 경우 경고 (자기 자신의 데이터 손상 위험)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 메뉴 복제 시도: ${sourceCompanyCode} → ${targetCompanyCode}`
|
||||
);
|
||||
warnings.push(
|
||||
"같은 회사로 복제하면 추가 데이터(카테고리, 채번 등)가 복제되지 않습니다."
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = await this.collectScreens(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
@@ -1116,6 +1126,10 @@ export class MenuCopyService {
|
||||
client
|
||||
);
|
||||
|
||||
// === 6.5단계: 메뉴 URL 업데이트 (화면 ID 재매핑) ===
|
||||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||
|
||||
// === 7단계: 테이블 타입 설정 복사 ===
|
||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||
@@ -1556,22 +1570,22 @@ export class MenuCopyService {
|
||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreenId = existingCopy.screen_id;
|
||||
|
||||
// 원본 레이아웃 조회
|
||||
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 원본 V2 레이아웃 조회
|
||||
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 대상 레이아웃 조회
|
||||
const targetLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 대상 V2 레이아웃 조회
|
||||
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
||||
const hasChanges = this.hasLayoutChanges(
|
||||
sourceLayoutsResult.rows,
|
||||
targetLayoutsResult.rows
|
||||
// 변경 여부 확인 (V2 레이아웃 비교)
|
||||
const hasChanges = this.hasLayoutChangesV2(
|
||||
sourceLayoutV2Result.rows[0]?.layout_data,
|
||||
targetLayoutV2Result.rows[0]?.layout_data
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
@@ -1673,9 +1687,9 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
|
||||
logger.info(
|
||||
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
|
||||
for (const {
|
||||
@@ -1685,91 +1699,51 @@ export class MenuCopyService {
|
||||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// 원본 레이아웃 조회
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 원본 V2 레이아웃 조회
|
||||
const layoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
if (isUpdate) {
|
||||
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
||||
[targetScreenId]
|
||||
const layoutData = layoutV2Result.rows[0]?.layout_data;
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
if (layoutData && components.length > 0) {
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
components.forEach((comp: any, idx: number) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(comp.id, newComponentId);
|
||||
});
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||
layoutData,
|
||||
componentIdMap,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||
}
|
||||
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
layoutsResult.rows.forEach((layout, idx) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(layout.component_id, newComponentId);
|
||||
});
|
||||
|
||||
// 레이아웃 배치 삽입 준비
|
||||
if (layoutsResult.rows.length > 0) {
|
||||
const layoutValues: string[] = [];
|
||||
const layoutParams: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||
|
||||
const newParentId = layout.parent_id
|
||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||
: null;
|
||||
const newZoneId = layout.zone_id
|
||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||
: null;
|
||||
|
||||
const updatedProperties = this.updateReferencesInProperties(
|
||||
layout.properties,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
|
||||
layoutValues.push(
|
||||
`($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})`
|
||||
);
|
||||
layoutParams.push(
|
||||
targetScreenId,
|
||||
layout.component_type,
|
||||
newComponentId,
|
||||
newParentId,
|
||||
layout.position_x,
|
||||
layout.position_y,
|
||||
layout.width,
|
||||
layout.height,
|
||||
updatedProperties,
|
||||
layout.display_order,
|
||||
layout.layout_type,
|
||||
layout.layout_config,
|
||||
layout.zones_config,
|
||||
newZoneId
|
||||
);
|
||||
paramIdx += 14;
|
||||
}
|
||||
|
||||
// 배치 INSERT
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (
|
||||
screen_id, component_type, component_id, parent_id,
|
||||
position_x, position_y, width, height, properties,
|
||||
display_order, layout_type, layout_config, zones_config, zone_id
|
||||
) VALUES ${layoutValues.join(", ")}`,
|
||||
layoutParams
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)]
|
||||
);
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
|
||||
} else {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
@@ -1835,6 +1809,83 @@ export class MenuCopyService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 변경 여부 확인 (screen_layouts_v2용)
|
||||
*/
|
||||
private hasLayoutChangesV2(
|
||||
sourceLayoutData: any,
|
||||
targetLayoutData: any
|
||||
): boolean {
|
||||
// 1. 둘 다 없으면 변경 없음
|
||||
if (!sourceLayoutData && !targetLayoutData) return false;
|
||||
|
||||
// 2. 하나만 있으면 변경됨
|
||||
if (!sourceLayoutData || !targetLayoutData) return true;
|
||||
|
||||
// 3. components 배열 비교
|
||||
const sourceComps = sourceLayoutData.components || [];
|
||||
const targetComps = targetLayoutData.components || [];
|
||||
|
||||
if (sourceComps.length !== targetComps.length) return true;
|
||||
|
||||
// 4. 각 컴포넌트 비교 (url, position, size, overrides)
|
||||
for (let i = 0; i < sourceComps.length; i++) {
|
||||
const s = sourceComps[i];
|
||||
const t = targetComps[i];
|
||||
|
||||
if (s.url !== t.url) return true;
|
||||
if (JSON.stringify(s.position) !== JSON.stringify(t.position)) return true;
|
||||
if (JSON.stringify(s.size) !== JSON.stringify(t.size)) return true;
|
||||
if (JSON.stringify(s.overrides) !== JSON.stringify(t.overrides)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 데이터의 참조 ID들을 업데이트 (componentId, flowId, ruleId, screenId, menuId)
|
||||
*/
|
||||
private updateReferencesInLayoutDataV2(
|
||||
layoutData: any,
|
||||
componentIdMap: Map<string, string>,
|
||||
screenIdMap: Map<number, number>,
|
||||
flowIdMap: Map<number, number>,
|
||||
numberingRuleIdMap?: Map<string, string>,
|
||||
menuIdMap?: Map<number, number>
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updatedComponents = layoutData.components.map((comp: any) => {
|
||||
// 1. componentId 매핑
|
||||
const newId = componentIdMap.get(comp.id) || comp.id;
|
||||
|
||||
// 2. overrides 복사 및 재귀적 참조 업데이트
|
||||
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
|
||||
|
||||
// 재귀적으로 모든 참조 업데이트
|
||||
this.recursiveUpdateReferences(
|
||||
overrides,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
"",
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
|
||||
return {
|
||||
...comp,
|
||||
id: newId,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...layoutData,
|
||||
components: updatedComponents,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 위상 정렬 (부모 먼저)
|
||||
*/
|
||||
@@ -2231,6 +2282,68 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||
*/
|
||||
private async updateMenuUrls(
|
||||
menuIdMap: Map<number, number>,
|
||||
screenIdMap: Map<number, number>,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMenuObjids = Array.from(menuIdMap.values());
|
||||
|
||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
||||
const menusWithUrl = await client.query<{
|
||||
objid: number;
|
||||
menu_url: string;
|
||||
}>(
|
||||
`SELECT objid, menu_url FROM menu_info
|
||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
||||
[newMenuObjids]
|
||||
);
|
||||
|
||||
if (menusWithUrl.rows.length === 0) {
|
||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusWithUrl.rows) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (!match) continue;
|
||||
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
const newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
||||
[newMenuUrl, menu.objid]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입)
|
||||
*/
|
||||
|
||||
@@ -1782,8 +1782,8 @@ class NumberingRuleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출)
|
||||
* 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결
|
||||
* 회사별 채번규칙 복제 (테이블 기반)
|
||||
* numbering_rules_test, numbering_rule_parts_test 테이블 사용
|
||||
* 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
|
||||
*/
|
||||
async copyRulesForCompany(
|
||||
@@ -1798,12 +1798,9 @@ class NumberingRuleService {
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두)
|
||||
// 1. 원본 회사의 채번규칙 조회 - numbering_rules_test 사용
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT nr.*, mi.menu_name_kor as source_menu_name
|
||||
FROM numbering_rules nr
|
||||
LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid
|
||||
WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`,
|
||||
`SELECT * FROM numbering_rules_test WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
@@ -1817,9 +1814,9 @@ class NumberingRuleService {
|
||||
// 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 이미 존재하는지 확인 (이름 기반)
|
||||
// 이미 존재하는지 확인 (이름 기반) - numbering_rules_test 사용
|
||||
const existsCheck = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
`SELECT rule_id FROM numbering_rules_test
|
||||
WHERE company_code = $1 AND rule_name = $2`,
|
||||
[targetCompanyCode, rule.rule_name]
|
||||
);
|
||||
@@ -1832,32 +1829,12 @@ class NumberingRuleService {
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetMenuObjid = null;
|
||||
|
||||
// menu 스코프인 경우 대상 메뉴 찾기
|
||||
if (rule.scope_type === 'menu' && rule.source_menu_name) {
|
||||
const targetMenuResult = await client.query(
|
||||
`SELECT objid FROM menu_info
|
||||
WHERE company_code = $1 AND menu_name_kor = $2
|
||||
LIMIT 1`,
|
||||
[targetCompanyCode, rule.source_menu_name]
|
||||
);
|
||||
|
||||
if (targetMenuResult.rows.length === 0) {
|
||||
result.skippedCount++;
|
||||
result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
targetMenuObjid = targetMenuResult.rows[0].objid;
|
||||
}
|
||||
|
||||
// 채번규칙 복제
|
||||
// 채번규칙 복제 - numbering_rules_test 사용
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
`INSERT INTO numbering_rules_test (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
created_at, updated_at, created_by, scope_type, menu_objid
|
||||
created_at, updated_at, created_by, category_column, category_value_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
@@ -1870,20 +1847,20 @@ class NumberingRuleService {
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
rule.created_by,
|
||||
rule.scope_type,
|
||||
targetMenuObjid,
|
||||
rule.category_column,
|
||||
rule.category_value_id,
|
||||
]
|
||||
);
|
||||
|
||||
// 채번규칙 파트 복제
|
||||
// 채번규칙 파트 복제 - numbering_rule_parts_test 사용
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
|
||||
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`,
|
||||
[rule.rule_id]
|
||||
);
|
||||
|
||||
for (const part of partsResult.rows) {
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
`INSERT INTO numbering_rule_parts_test (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||
@@ -1902,12 +1879,11 @@ class NumberingRuleService {
|
||||
// 매핑 추가
|
||||
result.ruleIdMap[rule.rule_id] = newRuleId;
|
||||
result.copiedCount++;
|
||||
result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`);
|
||||
result.details.push(`복제 완료: ${rule.rule_name}`);
|
||||
logger.info("채번규칙 복제 완료", {
|
||||
ruleName: rule.rule_name,
|
||||
oldRuleId: rule.rule_id,
|
||||
newRuleId,
|
||||
targetMenuObjid
|
||||
newRuleId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1665,18 +1665,28 @@ export class ScreenManagementService {
|
||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||
const layoutData = v2Layout.layout_data;
|
||||
|
||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||
const getTypeFromUrl = (url: string | undefined): string => {
|
||||
if (!url) return "component";
|
||||
const parts = url.split("/");
|
||||
return parts[parts.length - 1] || "component";
|
||||
};
|
||||
|
||||
// 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,
|
||||
}));
|
||||
const components = (layoutData.components || []).map((comp: any) => {
|
||||
const componentType = getTypeFromUrl(comp.url);
|
||||
return {
|
||||
id: comp.id,
|
||||
type: componentType,
|
||||
position: comp.position || { x: 0, y: 0, z: 1 },
|
||||
size: comp.size || { width: 200, height: 100 },
|
||||
componentUrl: comp.url,
|
||||
componentType: componentType,
|
||||
componentConfig: comp.overrides || {},
|
||||
displayOrder: comp.displayOrder || 0,
|
||||
...comp.overrides,
|
||||
};
|
||||
});
|
||||
|
||||
// screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산
|
||||
let screenResolution = layoutData.screenResolution;
|
||||
@@ -3471,6 +3481,371 @@ export class ScreenManagementService {
|
||||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃에서 flowId 수집 (screen_layouts_v2용)
|
||||
* - overrides.flowId (flow-widget)
|
||||
* - overrides.webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
|
||||
* - overrides.webTypeConfig.dataflowConfig.flowControls[].flowId
|
||||
* - overrides.action.excelAfterUploadFlows[].flowId
|
||||
*/
|
||||
private collectFlowIdsFromLayoutData(layoutData: any): Set<number> {
|
||||
const flowIds = new Set<number>();
|
||||
if (!layoutData?.components) return flowIds;
|
||||
|
||||
for (const comp of layoutData.components) {
|
||||
const overrides = comp.overrides || {};
|
||||
|
||||
// 1. overrides.flowId (flow-widget 등)
|
||||
if (overrides.flowId && !isNaN(parseInt(overrides.flowId))) {
|
||||
flowIds.add(parseInt(overrides.flowId));
|
||||
}
|
||||
|
||||
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
|
||||
const flowConfigId = overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
||||
if (flowConfigId && !isNaN(parseInt(flowConfigId))) {
|
||||
flowIds.add(parseInt(flowConfigId));
|
||||
}
|
||||
|
||||
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
|
||||
const diagramId = overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
|
||||
if (diagramId && !isNaN(parseInt(diagramId))) {
|
||||
flowIds.add(parseInt(diagramId));
|
||||
}
|
||||
|
||||
// 4. webTypeConfig.dataflowConfig.flowControls[].flowId
|
||||
const flowControls = overrides?.webTypeConfig?.dataflowConfig?.flowControls;
|
||||
if (Array.isArray(flowControls)) {
|
||||
for (const control of flowControls) {
|
||||
if (control?.flowId && !isNaN(parseInt(control.flowId))) {
|
||||
flowIds.add(parseInt(control.flowId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. action.excelAfterUploadFlows[].flowId
|
||||
const excelFlows = overrides?.action?.excelAfterUploadFlows;
|
||||
if (Array.isArray(excelFlows)) {
|
||||
for (const flow of excelFlows) {
|
||||
if (flow?.flowId && !isNaN(parseInt(flow.flowId))) {
|
||||
flowIds.add(parseInt(flow.flowId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃에서 numberingRuleId 수집 (screen_layouts_v2용)
|
||||
* - overrides.autoGeneration.options.numberingRuleId
|
||||
* - overrides.sections[].fields[].numberingRule.ruleId
|
||||
* - overrides.action.excelNumberingRuleId
|
||||
* - overrides.action.numberingRuleId
|
||||
*/
|
||||
private collectNumberingRuleIdsFromLayoutData(layoutData: any): Set<string> {
|
||||
const ruleIds = new Set<string>();
|
||||
if (!layoutData?.components) return ruleIds;
|
||||
|
||||
for (const comp of layoutData.components) {
|
||||
const overrides = comp.overrides || {};
|
||||
|
||||
// 1. autoGeneration.options.numberingRuleId
|
||||
const autoGenRuleId = overrides?.autoGeneration?.options?.numberingRuleId;
|
||||
if (autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(autoGenRuleId);
|
||||
}
|
||||
|
||||
// 2. sections[].fields[].numberingRule.ruleId
|
||||
const sections = overrides?.sections;
|
||||
if (Array.isArray(sections)) {
|
||||
for (const section of sections) {
|
||||
const fields = section?.fields;
|
||||
if (Array.isArray(fields)) {
|
||||
for (const field of fields) {
|
||||
const ruleId = field?.numberingRule?.ruleId;
|
||||
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
|
||||
ruleIds.add(ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// optionalFieldGroups 내부
|
||||
const optGroups = section?.optionalFieldGroups;
|
||||
if (Array.isArray(optGroups)) {
|
||||
for (const optGroup of optGroups) {
|
||||
const optFields = optGroup?.fields;
|
||||
if (Array.isArray(optFields)) {
|
||||
for (const field of optFields) {
|
||||
const ruleId = field?.numberingRule?.ruleId;
|
||||
if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) {
|
||||
ruleIds.add(ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. action.excelNumberingRuleId
|
||||
const excelRuleId = overrides?.action?.excelNumberingRuleId;
|
||||
if (excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(excelRuleId);
|
||||
}
|
||||
|
||||
// 4. action.numberingRuleId
|
||||
const actionRuleId = overrides?.action?.numberingRuleId;
|
||||
if (actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(actionRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
return ruleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 데이터의 참조 ID들을 업데이트
|
||||
* - componentId, flowId, numberingRuleId, screenId 매핑 적용
|
||||
*/
|
||||
private updateReferencesInLayoutData(
|
||||
layoutData: any,
|
||||
mappings: {
|
||||
componentIdMap: Map<string, string>;
|
||||
flowIdMap?: Map<number, number>;
|
||||
ruleIdMap?: Map<string, string>;
|
||||
screenIdMap?: Map<number, number>;
|
||||
},
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updatedComponents = layoutData.components.map((comp: any) => {
|
||||
// 1. componentId 매핑
|
||||
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
|
||||
|
||||
// 2. overrides 복사 및 참조 업데이트
|
||||
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
|
||||
|
||||
// flowId 매핑
|
||||
if (mappings.flowIdMap && mappings.flowIdMap.size > 0) {
|
||||
overrides = this.updateFlowIdsInOverrides(overrides, mappings.flowIdMap);
|
||||
}
|
||||
|
||||
// numberingRuleId 매핑
|
||||
if (mappings.ruleIdMap && mappings.ruleIdMap.size > 0) {
|
||||
overrides = this.updateNumberingRuleIdsInOverrides(overrides, mappings.ruleIdMap);
|
||||
}
|
||||
|
||||
// screenId 매핑 (탭, 버튼 등)
|
||||
if (mappings.screenIdMap && mappings.screenIdMap.size > 0) {
|
||||
overrides = this.updateScreenIdsInOverrides(overrides, mappings.screenIdMap);
|
||||
}
|
||||
|
||||
return {
|
||||
...comp,
|
||||
id: newId,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...layoutData,
|
||||
components: updatedComponents,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 flowId 업데이트
|
||||
*/
|
||||
private updateFlowIdsInOverrides(
|
||||
overrides: any,
|
||||
flowIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!overrides || flowIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. overrides.flowId (flow-widget)
|
||||
if (overrides.flowId) {
|
||||
const oldId = parseInt(overrides.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.flowId = newId;
|
||||
console.log(` 🔗 flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId
|
||||
if (overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
|
||||
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.flowConfig.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
|
||||
console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
|
||||
if (overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId) {
|
||||
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.selectedDiagramId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.webTypeConfig.dataflowConfig.selectedDiagramId = newId;
|
||||
console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. webTypeConfig.dataflowConfig.flowControls[]
|
||||
if (Array.isArray(overrides?.webTypeConfig?.dataflowConfig?.flowControls)) {
|
||||
for (const control of overrides.webTypeConfig.dataflowConfig.flowControls) {
|
||||
if (control?.flowId) {
|
||||
const oldId = parseInt(control.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
control.flowId = newId;
|
||||
console.log(` 🔗 flowControls.flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. action.excelAfterUploadFlows[]
|
||||
if (Array.isArray(overrides?.action?.excelAfterUploadFlows)) {
|
||||
for (const flow of overrides.action.excelAfterUploadFlows) {
|
||||
if (flow?.flowId) {
|
||||
const oldId = parseInt(flow.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
flow.flowId = newId;
|
||||
console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 numberingRuleId 업데이트
|
||||
*/
|
||||
private updateNumberingRuleIdsInOverrides(
|
||||
overrides: any,
|
||||
ruleIdMap: Map<string, string>,
|
||||
): any {
|
||||
if (!overrides || ruleIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. autoGeneration.options.numberingRuleId
|
||||
if (overrides?.autoGeneration?.options?.numberingRuleId) {
|
||||
const oldId = overrides.autoGeneration.options.numberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.autoGeneration.options.numberingRuleId = newId;
|
||||
console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. sections[].fields[].numberingRule.ruleId
|
||||
if (Array.isArray(overrides?.sections)) {
|
||||
for (const section of overrides.sections) {
|
||||
if (Array.isArray(section?.fields)) {
|
||||
for (const field of section.fields) {
|
||||
if (field?.numberingRule?.ruleId) {
|
||||
const oldId = field.numberingRule.ruleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
field.numberingRule.ruleId = newId;
|
||||
console.log(` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(section?.optionalFieldGroups)) {
|
||||
for (const optGroup of section.optionalFieldGroups) {
|
||||
if (Array.isArray(optGroup?.fields)) {
|
||||
for (const field of optGroup.fields) {
|
||||
if (field?.numberingRule?.ruleId) {
|
||||
const oldId = field.numberingRule.ruleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
field.numberingRule.ruleId = newId;
|
||||
console.log(` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. action.excelNumberingRuleId
|
||||
if (overrides?.action?.excelNumberingRuleId) {
|
||||
const oldId = overrides.action.excelNumberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.excelNumberingRuleId = newId;
|
||||
console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. action.numberingRuleId
|
||||
if (overrides?.action?.numberingRuleId) {
|
||||
const oldId = overrides.action.numberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.numberingRuleId = newId;
|
||||
console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 screenId 업데이트 (탭, 버튼 등)
|
||||
*/
|
||||
private updateScreenIdsInOverrides(
|
||||
overrides: any,
|
||||
screenIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!overrides || screenIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. tabs[].screenId (탭 위젯)
|
||||
if (Array.isArray(overrides?.tabs)) {
|
||||
for (const tab of overrides.tabs) {
|
||||
if (tab?.screenId) {
|
||||
const oldId = parseInt(tab.screenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
tab.screenId = newId;
|
||||
console.log(` 🔗 tab.screenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. action.targetScreenId (버튼)
|
||||
if (overrides?.action?.targetScreenId) {
|
||||
const oldId = parseInt(overrides.action.targetScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.targetScreenId = newId;
|
||||
console.log(` 🔗 action.targetScreenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. action.modalScreenId
|
||||
if (overrides?.action?.modalScreenId) {
|
||||
const oldId = parseInt(overrides.action.modalScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.modalScreenId = newId;
|
||||
console.log(` 🔗 action.modalScreenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 복사 및 ID 매핑 반환
|
||||
* - 원본 회사의 플로우를 대상 회사로 복사
|
||||
@@ -3709,24 +4084,34 @@ export class ScreenManagementService {
|
||||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
|
||||
// 4. 원본 화면의 레이아웃 정보 조회
|
||||
const sourceLayoutsResult = await client.query<any>(
|
||||
`SELECT * FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
ORDER BY display_order ASC NULLS LAST`,
|
||||
[sourceScreenId],
|
||||
// 4. 원본 화면의 V2 레이아웃 조회
|
||||
let sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[sourceScreenId, sourceScreen.company_code],
|
||||
);
|
||||
|
||||
const sourceLayouts = sourceLayoutsResult.rows;
|
||||
// 없으면 공통(*) 레이아웃 조회
|
||||
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data;
|
||||
if (!layoutData && sourceScreen.company_code !== "*") {
|
||||
const fallbackResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
||||
let flowIdMap = new Map<number, number>();
|
||||
if (
|
||||
sourceLayouts.length > 0 &&
|
||||
components.length > 0 &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// 레이아웃에서 사용하는 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts);
|
||||
// V2 레이아웃에서 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
||||
|
||||
if (flowIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
||||
@@ -3744,11 +4129,11 @@ export class ScreenManagementService {
|
||||
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
||||
let ruleIdMap = new Map<string, string>();
|
||||
if (
|
||||
sourceLayouts.length > 0 &&
|
||||
components.length > 0 &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// 레이아웃에서 사용하는 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts);
|
||||
// V2 레이아웃에서 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
|
||||
|
||||
if (ruleIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
||||
@@ -3763,81 +4148,43 @@ export class ScreenManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 레이아웃이 있다면 복사
|
||||
if (sourceLayouts.length > 0) {
|
||||
// 6. V2 레이아웃이 있다면 복사
|
||||
if (layoutData && components.length > 0) {
|
||||
try {
|
||||
// ID 매핑 맵 생성
|
||||
const idMapping: { [oldId: string]: string } = {};
|
||||
|
||||
// 새로운 컴포넌트 ID 미리 생성
|
||||
sourceLayouts.forEach((layout: any) => {
|
||||
idMapping[layout.component_id] = generateId();
|
||||
});
|
||||
|
||||
// 각 레이아웃 컴포넌트 복사
|
||||
for (const sourceLayout of sourceLayouts) {
|
||||
const newComponentId = idMapping[sourceLayout.component_id];
|
||||
const newParentId = sourceLayout.parent_id
|
||||
? idMapping[sourceLayout.parent_id]
|
||||
: null;
|
||||
|
||||
// properties 파싱
|
||||
let properties = sourceLayout.properties;
|
||||
if (typeof properties === "string") {
|
||||
try {
|
||||
properties = JSON.parse(properties);
|
||||
} catch (e) {
|
||||
// 파싱 실패 시 그대로 사용
|
||||
}
|
||||
}
|
||||
|
||||
// flowId 매핑 적용 (회사가 다른 경우)
|
||||
if (flowIdMap.size > 0) {
|
||||
properties = this.updateFlowIdsInProperties(
|
||||
properties,
|
||||
flowIdMap,
|
||||
);
|
||||
}
|
||||
|
||||
// 채번 규칙 ID 매핑 적용 (회사가 다른 경우)
|
||||
if (ruleIdMap.size > 0) {
|
||||
properties = this.updateNumberingRuleIdsInProperties(
|
||||
properties,
|
||||
ruleIdMap,
|
||||
);
|
||||
}
|
||||
|
||||
// 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음
|
||||
// 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (
|
||||
screen_id, component_type, component_id, parent_id,
|
||||
position_x, position_y, width, height, properties,
|
||||
display_order, created_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
newScreen.screen_id,
|
||||
sourceLayout.component_type,
|
||||
newComponentId,
|
||||
newParentId,
|
||||
Math.round(sourceLayout.position_x), // 정수로 반올림
|
||||
Math.round(sourceLayout.position_y), // 정수로 반올림
|
||||
Math.round(sourceLayout.width), // 정수로 반올림
|
||||
Math.round(sourceLayout.height), // 정수로 반올림
|
||||
JSON.stringify(properties),
|
||||
sourceLayout.display_order,
|
||||
new Date(),
|
||||
],
|
||||
);
|
||||
// componentId 매핑 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const comp of components) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
}
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
||||
},
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
||||
} catch (error) {
|
||||
console.error("레이아웃 복사 중 오류:", error);
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 생성된 화면 정보 반환
|
||||
// 7. 생성된 화면 정보 반환
|
||||
return {
|
||||
screenId: newScreen.screen_id,
|
||||
screenCode: newScreen.screen_code,
|
||||
@@ -4248,6 +4595,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 코드 카테고리/코드 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
@@ -4369,12 +4725,21 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 카테고리 값 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
);
|
||||
|
||||
// 1. 기존 대상 회사 데이터 삭제
|
||||
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
|
||||
await client.query(
|
||||
`DELETE FROM category_values_test WHERE company_code = $1`,
|
||||
[targetCompanyCode],
|
||||
@@ -4451,6 +4816,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 테이블 타입 컬럼 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
@@ -4514,6 +4888,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 연쇄관계 설정 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
|
||||
670
docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md
Normal file
670
docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md
Normal file
@@ -0,0 +1,670 @@
|
||||
# 반응형 그리드 시스템 아키텍처
|
||||
|
||||
> 최종 업데이트: 2026-01-30
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 현재 문제
|
||||
|
||||
**컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원**
|
||||
|
||||
```json
|
||||
// 현재 DB 저장 방식 (screen_layouts_v2.layout_data)
|
||||
{
|
||||
"position": { "x": 1753, "y": 88 },
|
||||
"size": { "width": 158, "height": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
| 화면 크기 | 결과 |
|
||||
|-----------|------|
|
||||
| 1920px (디자인 기준) | 정상 |
|
||||
| 1280px (노트북) | 오른쪽 버튼 잘림 |
|
||||
| 768px (태블릿) | 레이아웃 완전히 깨짐 |
|
||||
| 375px (모바일) | 사용 불가 |
|
||||
|
||||
### 1.2 목표
|
||||
|
||||
| 목표 | 설명 |
|
||||
|------|------|
|
||||
| PC 대응 | 1280px ~ 1920px |
|
||||
| 태블릿 대응 | 768px ~ 1024px |
|
||||
| 모바일 대응 | 320px ~ 767px |
|
||||
|
||||
### 1.3 해결 방향
|
||||
|
||||
```
|
||||
현재: 픽셀 좌표 → position: absolute → 고정 레이아웃
|
||||
변경: 그리드 셀 번호 → CSS Grid + ResizeObserver → 반응형 레이아웃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 시스템 분석
|
||||
|
||||
### 2.1 데이터 현황
|
||||
|
||||
```
|
||||
총 레이아웃: 1,250개
|
||||
총 컴포넌트: 5,236개
|
||||
회사 수: 14개
|
||||
테이블 크기: 약 3MB
|
||||
```
|
||||
|
||||
### 2.2 컴포넌트 타입별 분포
|
||||
|
||||
| 컴포넌트 | 수량 | shadcn 사용 |
|
||||
|----------|------|-------------|
|
||||
| v2-input | 1,914 | ✅ `@/components/ui/input` |
|
||||
| v2-button-primary | 1,549 | ✅ `@/components/ui/button` |
|
||||
| v2-table-search-widget | 355 | ✅ shadcn 기반 |
|
||||
| v2-select | 327 | ✅ `@/components/ui/select` |
|
||||
| v2-table-list | 285 | ✅ `@/components/ui/table` |
|
||||
| v2-media | 181 | ✅ shadcn 기반 |
|
||||
| v2-date | 132 | ✅ `@/components/ui/calendar` |
|
||||
| **v2-split-panel-layout** | **131** | ✅ shadcn 기반 (**반응형 필요**) |
|
||||
| v2-tabs-widget | 75 | ✅ shadcn 기반 |
|
||||
| 기타 | 287 | ✅ shadcn 기반 |
|
||||
| **합계** | **5,236** | **전부 shadcn** |
|
||||
|
||||
### 2.3 현재 렌더링 방식
|
||||
|
||||
```tsx
|
||||
// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248)
|
||||
{components.map((child) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute", // 절대 위치
|
||||
left: child.position.x, // 픽셀 고정
|
||||
top: child.position.y, // 픽셀 고정
|
||||
width: child.size.width, // 픽셀 고정
|
||||
height: child.size.height, // 픽셀 고정
|
||||
}}
|
||||
>
|
||||
{renderer.renderChild(child)}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
### 2.4 핵심 발견
|
||||
|
||||
```
|
||||
✅ 이미 있는 것:
|
||||
- 12컬럼 그리드 설정 (gridSettings.columns: 12)
|
||||
- 그리드 스냅 기능 (snapToGrid: true)
|
||||
- shadcn/ui 기반 컴포넌트 (전체)
|
||||
|
||||
❌ 없는 것:
|
||||
- 그리드 셀 번호 저장 (현재 픽셀 저장)
|
||||
- 반응형 브레이크포인트 설정
|
||||
- CSS Grid 기반 렌더링
|
||||
- 분할 패널 반응형 처리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술 결정
|
||||
|
||||
### 3.1 왜 Tailwind 동적 클래스가 아닌 CSS Grid + Inline Style인가?
|
||||
|
||||
**Tailwind 동적 클래스의 한계**:
|
||||
```tsx
|
||||
// ❌ 이건 안 됨 - Tailwind가 빌드 타임에 인식 못함
|
||||
className={`col-start-${col} md:col-start-${mdCol}`}
|
||||
|
||||
// ✅ 이것만 됨 - 정적 클래스
|
||||
className="col-start-1 md:col-start-3"
|
||||
```
|
||||
|
||||
Tailwind는 **빌드 타임**에 클래스를 스캔하므로, 런타임에 동적으로 생성되는 클래스는 인식하지 못합니다.
|
||||
|
||||
**해결책: CSS Grid + Inline Style + ResizeObserver**:
|
||||
```tsx
|
||||
// ✅ 올바른 방법
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||
}}>
|
||||
<div style={{
|
||||
gridColumn: `${col} / span ${colSpan}`, // 동적 값 가능
|
||||
}}>
|
||||
{component}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3.2 역할 분담
|
||||
|
||||
| 영역 | 기술 | 설명 |
|
||||
|------|------|------|
|
||||
| **UI 컴포넌트** | shadcn/ui | 버튼, 인풋, 테이블 등 (이미 적용됨) |
|
||||
| **레이아웃 배치** | CSS Grid + Inline Style | 컴포넌트 위치, 크기, 반응형 |
|
||||
| **반응형 감지** | ResizeObserver | 화면 크기 감지 및 브레이크포인트 변경 |
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ResponsiveGridLayout (CSS Grid) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ shadcn │ │ shadcn │ │ shadcn │ │
|
||||
│ │ Button │ │ Input │ │ Select │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ shadcn Table │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 구조 변경
|
||||
|
||||
### 4.1 현재 구조 (V2)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1753, "y": 88, "z": 1 },
|
||||
"size": { "width": 158, "height": 40 },
|
||||
"overrides": { ... }
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 변경 후 구조 (V2 + 그리드)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"layoutMode": "grid",
|
||||
"components": [{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/components/v2-button-primary",
|
||||
"position": { "x": 1753, "y": 88, "z": 1 },
|
||||
"size": { "width": 158, "height": 40 },
|
||||
"grid": {
|
||||
"col": 11,
|
||||
"row": 2,
|
||||
"colSpan": 1,
|
||||
"rowSpan": 1
|
||||
},
|
||||
"responsive": {
|
||||
"sm": { "col": 1, "colSpan": 12 },
|
||||
"md": { "col": 7, "colSpan": 6 },
|
||||
"lg": { "col": 11, "colSpan": 1 }
|
||||
},
|
||||
"overrides": { ... }
|
||||
}],
|
||||
"gridSettings": {
|
||||
"columns": 12,
|
||||
"rowHeight": 80,
|
||||
"gap": 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 필드 설명
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `layoutMode` | string | "grid" (반응형 그리드 사용) |
|
||||
| `grid.col` | number | 시작 컬럼 (1-12) |
|
||||
| `grid.row` | number | 시작 행 (1부터) |
|
||||
| `grid.colSpan` | number | 차지하는 컬럼 수 |
|
||||
| `grid.rowSpan` | number | 차지하는 행 수 |
|
||||
| `responsive.sm` | object | 모바일 (< 768px) 설정 |
|
||||
| `responsive.md` | object | 태블릿 (768px ~ 1024px) 설정 |
|
||||
| `responsive.lg` | object | 데스크톱 (> 1024px) 설정 |
|
||||
|
||||
### 4.4 호환성
|
||||
|
||||
- `position`, `size` 필드는 유지 (디자인 모드 + 폴백용)
|
||||
- `layoutMode`가 없으면 기존 방식(absolute) 사용
|
||||
- 마이그레이션 후에도 기존 화면 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 상세
|
||||
|
||||
### 5.1 그리드 변환 유틸리티
|
||||
|
||||
```typescript
|
||||
// frontend/lib/utils/gridConverter.ts
|
||||
|
||||
const DESIGN_WIDTH = 1920;
|
||||
const COLUMNS = 12;
|
||||
const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px
|
||||
const ROW_HEIGHT = 80;
|
||||
|
||||
/**
|
||||
* 픽셀 좌표를 그리드 셀 번호로 변환
|
||||
*/
|
||||
export function pixelToGrid(
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
): GridPosition {
|
||||
return {
|
||||
col: Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)),
|
||||
row: Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1),
|
||||
colSpan: Math.max(1, Math.round(size.width / COLUMN_WIDTH)),
|
||||
rowSpan: Math.max(1, Math.round(size.height / ROW_HEIGHT)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 반응형 설정 생성
|
||||
*/
|
||||
export function getDefaultResponsive(grid: GridPosition): ResponsiveConfig {
|
||||
return {
|
||||
sm: { col: 1, colSpan: 12 }, // 모바일: 전체 너비
|
||||
md: {
|
||||
col: Math.max(1, Math.round(grid.col / 2)),
|
||||
colSpan: Math.min(grid.colSpan * 2, 12)
|
||||
}, // 태블릿: 2배 확장
|
||||
lg: { col: grid.col, colSpan: grid.colSpan }, // 데스크톱: 원본
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 반응형 그리드 레이아웃 컴포넌트
|
||||
|
||||
```tsx
|
||||
// frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx
|
||||
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
|
||||
type Breakpoint = "sm" | "md" | "lg";
|
||||
|
||||
interface ResponsiveGridLayoutProps {
|
||||
layout: LayoutData;
|
||||
isDesignMode: boolean;
|
||||
renderer: ComponentRenderer;
|
||||
}
|
||||
|
||||
export function ResponsiveGridLayout({
|
||||
layout,
|
||||
isDesignMode,
|
||||
renderer,
|
||||
}: ResponsiveGridLayoutProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
|
||||
|
||||
// 화면 크기 감지
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0].contentRect.width;
|
||||
if (width < 768) setBreakpoint("sm");
|
||||
else if (width < 1024) setBreakpoint("md");
|
||||
else setBreakpoint("lg");
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const gridSettings = layout.gridSettings || { columns: 12, rowHeight: 80, gap: 16 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridSettings.columns}, 1fr)`,
|
||||
gridAutoRows: `${gridSettings.rowHeight}px`,
|
||||
gap: `${gridSettings.gap}px`,
|
||||
minHeight: isDesignMode ? "600px" : "auto",
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
.sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0))
|
||||
.map((component) => {
|
||||
// 반응형 설정 가져오기
|
||||
const gridConfig = component.responsive?.[breakpoint] || component.grid;
|
||||
const { col, colSpan } = gridConfig;
|
||||
const rowSpan = component.grid?.rowSpan || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
gridColumn: `${col} / span ${colSpan}`,
|
||||
gridRow: `span ${rowSpan}`,
|
||||
}}
|
||||
>
|
||||
{renderer.renderChild(component)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 브레이크포인트 훅
|
||||
|
||||
```typescript
|
||||
// frontend/lib/registry/layouts/responsive-grid/useBreakpoint.ts
|
||||
|
||||
import { useState, useEffect, RefObject } from "react";
|
||||
|
||||
type Breakpoint = "sm" | "md" | "lg";
|
||||
|
||||
export function useBreakpoint(containerRef: RefObject<HTMLElement>): Breakpoint {
|
||||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("lg");
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0].contentRect.width;
|
||||
if (width < 768) setBreakpoint("sm");
|
||||
else if (width < 1024) setBreakpoint("md");
|
||||
else setBreakpoint("lg");
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 분할 패널 반응형 수정
|
||||
|
||||
```tsx
|
||||
// frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
|
||||
|
||||
// 추가할 코드
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0].contentRect.width;
|
||||
setIsMobile(width < 768);
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 렌더링 부분 수정
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"flex h-full",
|
||||
isMobile ? "flex-col" : "flex-row" // 모바일: 상하, 데스크톱: 좌우
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
width: isMobile ? "100%" : `${leftWidth}%`,
|
||||
minHeight: isMobile ? "300px" : "auto"
|
||||
}}>
|
||||
{/* 좌측/상단 패널 */}
|
||||
</div>
|
||||
<div style={{
|
||||
width: isMobile ? "100%" : `${100 - leftWidth}%`,
|
||||
minHeight: isMobile ? "300px" : "auto"
|
||||
}}>
|
||||
{/* 우측/하단 패널 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 렌더링 분기 처리
|
||||
|
||||
```typescript
|
||||
// frontend/lib/registry/DynamicComponentRenderer.tsx
|
||||
|
||||
function renderLayout(layout: LayoutData) {
|
||||
// layoutMode에 따라 분기
|
||||
if (layout.layoutMode === "grid") {
|
||||
return <ResponsiveGridLayout layout={layout} renderer={this} />;
|
||||
}
|
||||
|
||||
// 기존 방식 (폴백)
|
||||
return <FlexboxLayout layout={layout} renderer={this} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션
|
||||
|
||||
### 7.1 백업
|
||||
|
||||
```sql
|
||||
-- 마이그레이션 전 백업
|
||||
CREATE TABLE screen_layouts_v2_backup_20260130 AS
|
||||
SELECT * FROM screen_layouts_v2;
|
||||
```
|
||||
|
||||
### 7.2 마이그레이션 스크립트
|
||||
|
||||
```sql
|
||||
-- grid, responsive 필드 추가
|
||||
UPDATE screen_layouts_v2
|
||||
SET layout_data = (
|
||||
SELECT jsonb_set(
|
||||
jsonb_set(
|
||||
layout_data,
|
||||
'{layoutMode}',
|
||||
'"grid"'
|
||||
),
|
||||
'{components}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
comp || jsonb_build_object(
|
||||
'grid', jsonb_build_object(
|
||||
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
|
||||
'row', GREATEST(1, ROUND((comp->'position'->>'y')::NUMERIC / 80) + 1),
|
||||
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)),
|
||||
'rowSpan', GREATEST(1, ROUND((comp->'size'->>'height')::NUMERIC / 80))
|
||||
),
|
||||
'responsive', jsonb_build_object(
|
||||
'sm', jsonb_build_object('col', 1, 'colSpan', 12),
|
||||
'md', jsonb_build_object(
|
||||
'col', GREATEST(1, ROUND((ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1) / 2.0)),
|
||||
'colSpan', LEAST(ROUND((comp->'size'->>'width')::NUMERIC / 160) * 2, 12)
|
||||
),
|
||||
'lg', jsonb_build_object(
|
||||
'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)),
|
||||
'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM jsonb_array_elements(layout_data->'components') as comp
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 7.3 롤백
|
||||
|
||||
```sql
|
||||
-- 문제 발생 시 롤백
|
||||
DROP TABLE screen_layouts_v2;
|
||||
ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 동작 흐름
|
||||
|
||||
### 8.1 데스크톱 (> 1024px)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 1 2 3 4 5 6 7 8 9 10 │ 11 12 │ │
|
||||
│ │ [버튼] │ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 테이블 (12컬럼) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 태블릿 (768px ~ 1024px)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1 2 3 4 5 6 │ 7 8 9 10 11 12 │
|
||||
│ │ [버튼] │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ 테이블 (12컬럼) │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.3 모바일 (< 768px)
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ [버튼] │ ← 12컬럼 (전체 너비)
|
||||
├──────────────────┤
|
||||
│ │
|
||||
│ 테이블 (스크롤) │ ← 12컬럼 (전체 너비)
|
||||
│ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 8.4 분할 패널 (반응형)
|
||||
|
||||
**데스크톱**:
|
||||
```
|
||||
┌─────────────────────────┬─────────────────────────┐
|
||||
│ 좌측 패널 (60%) │ 우측 패널 (40%) │
|
||||
└─────────────────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
**모바일**:
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ 상단 패널 (이전 좌측) │
|
||||
├─────────────────────────┤
|
||||
│ 하단 패널 (이전 우측) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 수정 파일 목록
|
||||
|
||||
### 9.1 새로 생성
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `lib/utils/gridConverter.ts` | 픽셀 → 그리드 변환 유틸리티 |
|
||||
| `lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx` | CSS Grid 레이아웃 |
|
||||
| `lib/registry/layouts/responsive-grid/useBreakpoint.ts` | ResizeObserver 훅 |
|
||||
| `lib/registry/layouts/responsive-grid/index.ts` | 모듈 export |
|
||||
|
||||
### 9.2 수정
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|-----------|
|
||||
| `lib/registry/DynamicComponentRenderer.tsx` | layoutMode 분기 추가 |
|
||||
| `components/screen/ScreenDesigner.tsx` | 저장 시 grid/responsive 생성 |
|
||||
| `v2-split-panel-layout/SplitPanelLayoutComponent.tsx` | 반응형 처리 추가 |
|
||||
|
||||
### 9.3 수정 없음
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `v2-input/*` | 레이아웃과 무관 (shadcn 그대로) |
|
||||
| `v2-button-primary/*` | 레이아웃과 무관 (shadcn 그대로) |
|
||||
| `v2-table-list/*` | 레이아웃과 무관 (shadcn 그대로) |
|
||||
| `v2-select/*` | 레이아웃과 무관 (shadcn 그대로) |
|
||||
| **...모든 v2 컴포넌트** | **수정 불필요** |
|
||||
|
||||
---
|
||||
|
||||
## 10. 작업 일정
|
||||
|
||||
| Phase | 작업 | 파일 | 시간 |
|
||||
|-------|------|------|------|
|
||||
| **1** | 그리드 변환 유틸리티 | `gridConverter.ts` | 2시간 |
|
||||
| **1** | 브레이크포인트 훅 | `useBreakpoint.ts` | 1시간 |
|
||||
| **2** | ResponsiveGridLayout | `ResponsiveGridLayout.tsx` | 4시간 |
|
||||
| **2** | 렌더링 분기 처리 | `DynamicComponentRenderer.tsx` | 1시간 |
|
||||
| **3** | 저장 로직 수정 | `ScreenDesigner.tsx` | 2시간 |
|
||||
| **3** | 분할 패널 반응형 | `SplitPanelLayoutComponent.tsx` | 3시간 |
|
||||
| **4** | 마이그레이션 스크립트 | SQL | 2시간 |
|
||||
| **4** | 마이그레이션 실행 | - | 1시간 |
|
||||
| **5** | 테스트 및 버그 수정 | - | 4시간 |
|
||||
| | **합계** | | **약 2.5일** |
|
||||
|
||||
---
|
||||
|
||||
## 11. 체크리스트
|
||||
|
||||
### 개발 전
|
||||
|
||||
- [ ] screen_layouts_v2 백업 완료
|
||||
- [ ] 개발 환경에서 테스트 데이터 준비
|
||||
|
||||
### Phase 1: 유틸리티
|
||||
|
||||
- [ ] `gridConverter.ts` 생성
|
||||
- [ ] `useBreakpoint.ts` 생성
|
||||
- [ ] 단위 테스트 작성
|
||||
|
||||
### Phase 2: 레이아웃
|
||||
|
||||
- [ ] `ResponsiveGridLayout.tsx` 생성
|
||||
- [ ] `DynamicComponentRenderer.tsx` 분기 추가
|
||||
- [ ] 기존 화면 정상 동작 확인
|
||||
|
||||
### Phase 3: 저장/수정
|
||||
|
||||
- [ ] `ScreenDesigner.tsx` 저장 로직 수정
|
||||
- [ ] `SplitPanelLayoutComponent.tsx` 반응형 추가
|
||||
- [ ] 디자인 모드 테스트
|
||||
|
||||
### Phase 4: 마이그레이션
|
||||
|
||||
- [ ] 마이그레이션 스크립트 테스트 (개발 DB)
|
||||
- [ ] 운영 DB 백업
|
||||
- [ ] 마이그레이션 실행
|
||||
- [ ] 검증
|
||||
|
||||
### Phase 5: 테스트
|
||||
|
||||
- [ ] PC (1920px, 1280px) 테스트
|
||||
- [ ] 태블릿 (768px, 1024px) 테스트
|
||||
- [ ] 모바일 (375px, 414px) 테스트
|
||||
- [ ] 분할 패널 화면 테스트
|
||||
|
||||
---
|
||||
|
||||
## 12. 리스크 및 대응
|
||||
|
||||
| 리스크 | 영향 | 대응 |
|
||||
|--------|------|------|
|
||||
| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 |
|
||||
| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) |
|
||||
| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고
|
||||
|
||||
- [COMPONENT_LAYOUT_V2_ARCHITECTURE.md](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) - V2 아키텍처
|
||||
- [CSS Grid Layout - MDN](https://developer.mozilla.org/ko/docs/Web/CSS/CSS_Grid_Layout)
|
||||
- [ResizeObserver - MDN](https://developer.mozilla.org/ko/docs/Web/API/ResizeObserver)
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - 컴포넌트 라이브러리
|
||||
524
docs/SCREEN_COPY_V2_MIGRATION_PLAN.md
Normal file
524
docs/SCREEN_COPY_V2_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# 화면 복제 로직 V2 마이그레이션 계획서
|
||||
|
||||
> 작성일: 2026-01-28
|
||||
|
||||
## 1. 현황 분석
|
||||
|
||||
### 1.1 현재 복제 방식 (Legacy)
|
||||
|
||||
```
|
||||
테이블: screen_layouts (다중 레코드)
|
||||
방식: 화면당 N개 레코드 (컴포넌트 수만큼)
|
||||
저장: properties에 전체 설정 "박제"
|
||||
```
|
||||
|
||||
**데이터 구조:**
|
||||
```sql
|
||||
-- 화면당 여러 레코드
|
||||
SELECT * FROM screen_layouts WHERE screen_id = 123;
|
||||
-- layout_id | screen_id | component_type | component_id | properties (전체 설정)
|
||||
-- 1 | 123 | table-list | comp_001 | {"tableName": "user", "columns": [...], ...}
|
||||
-- 2 | 123 | button | comp_002 | {"label": "저장", "variant": "default", ...}
|
||||
```
|
||||
|
||||
### 1.2 V2 방식
|
||||
|
||||
```
|
||||
테이블: screen_layouts_v2 (1개 레코드)
|
||||
방식: 화면당 1개 레코드 (JSONB)
|
||||
저장: url + overrides (차이값만)
|
||||
```
|
||||
|
||||
**데이터 구조:**
|
||||
```sql
|
||||
-- 화면당 1개 레코드
|
||||
SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = 123;
|
||||
-- {
|
||||
-- "version": "2.0",
|
||||
-- "components": [
|
||||
-- { "id": "comp_001", "url": "@/lib/registry/components/table-list", "overrides": {...} },
|
||||
-- { "id": "comp_002", "url": "@/lib/registry/components/button-primary", "overrides": {...} }
|
||||
-- ]
|
||||
-- }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 복제 로직 분석
|
||||
|
||||
### 2.1 복제 진입점 (2곳)
|
||||
|
||||
| 경로 | 파일 | 함수 | 용도 |
|
||||
|-----|------|------|-----|
|
||||
| 단일 화면 복제 | `screenManagementService.ts` | `copyScreen()` | 화면 관리에서 개별 화면 복제 |
|
||||
| 메뉴 일괄 복제 | `menuCopyService.ts` | `copyScreens()` | 메뉴 복제 시 연결된 화면들 복제 |
|
||||
|
||||
### 2.2 screenManagementService.copyScreen() 흐름
|
||||
|
||||
```
|
||||
1. screen_definitions 조회 (원본)
|
||||
2. screen_definitions INSERT (대상)
|
||||
3. screen_layouts 조회 (원본) ← Legacy
|
||||
4. flowId 수집 및 복제 (회사 간 복제 시)
|
||||
5. numberingRuleId 수집 및 복제 (회사 간 복제 시)
|
||||
6. componentId 재생성 (idMapping)
|
||||
7. properties 내 참조 업데이트 (flowId, ruleId)
|
||||
8. screen_layouts INSERT (대상) ← Legacy
|
||||
```
|
||||
|
||||
**V2 처리: ❌ 없음**
|
||||
|
||||
### 2.3 menuCopyService.copyScreens() 흐름
|
||||
|
||||
```
|
||||
1단계: screen_definitions 처리
|
||||
- 기존 복사본 존재 시: 업데이트
|
||||
- 없으면: 신규 생성
|
||||
- screenIdMap 생성
|
||||
|
||||
2단계: screen_layouts 처리
|
||||
- 원본 조회
|
||||
- componentIdMap 생성
|
||||
- properties 내 참조 업데이트 (screenId, flowId, ruleId, menuId)
|
||||
- 배치 INSERT
|
||||
```
|
||||
|
||||
**V2 처리: ❌ 없음**
|
||||
|
||||
### 2.4 복제 시 처리되는 참조 ID들
|
||||
|
||||
| 참조 ID | 설명 | 매핑 방식 |
|
||||
|--------|-----|----------|
|
||||
| `componentId` | 컴포넌트 고유 ID | 새로 생성 (`comp_xxx`) |
|
||||
| `parentId` | 부모 컴포넌트 ID | componentIdMap으로 매핑 |
|
||||
| `flowId` | 노드 플로우 ID | flowIdMap으로 매핑 (회사 간 복제 시) |
|
||||
| `numberingRuleId` | 채번 규칙 ID | ruleIdMap으로 매핑 (회사 간 복제 시) |
|
||||
| `screenId` (탭) | 탭에서 참조하는 화면 ID | screenIdMap으로 매핑 |
|
||||
| `menuObjid` | 메뉴 ID | menuIdMap으로 매핑 |
|
||||
|
||||
---
|
||||
|
||||
## 3. V2 마이그레이션 시 변경 필요 사항
|
||||
|
||||
### 3.1 핵심 변경점
|
||||
|
||||
| 항목 | Legacy | V2 |
|
||||
|-----|--------|-----|
|
||||
| 읽기 테이블 | `screen_layouts` | `screen_layouts_v2` |
|
||||
| 쓰기 테이블 | `screen_layouts` | `screen_layouts_v2` |
|
||||
| 데이터 형태 | N개 레코드 | 1개 JSONB |
|
||||
| ID 매핑 위치 | 각 레코드의 컬럼 | JSONB 내부 순회 |
|
||||
| 참조 업데이트 | `properties` JSON | `overrides` JSON |
|
||||
|
||||
### 3.2 수정해야 할 함수들
|
||||
|
||||
#### screenManagementService.ts
|
||||
|
||||
| 함수 | 변경 내용 |
|
||||
|-----|----------|
|
||||
| `copyScreen()` | screen_layouts_v2 복제 로직 추가 |
|
||||
| `collectFlowIdsFromLayouts()` | V2 JSONB 구조에서 flowId 수집 |
|
||||
| `collectNumberingRuleIdsFromLayouts()` | V2 JSONB 구조에서 ruleId 수집 |
|
||||
| `updateFlowIdsInProperties()` | V2 overrides 내 flowId 업데이트 |
|
||||
| `updateNumberingRuleIdsInProperties()` | V2 overrides 내 ruleId 업데이트 |
|
||||
|
||||
#### menuCopyService.ts
|
||||
|
||||
| 함수 | 변경 내용 |
|
||||
|-----|----------|
|
||||
| `copyScreens()` | screen_layouts_v2 복제 로직 추가 |
|
||||
| `hasLayoutChanges()` | V2 JSONB 비교 로직 |
|
||||
| `updateReferencesInProperties()` | V2 overrides 내 참조 업데이트 |
|
||||
|
||||
### 3.3 새로 추가할 함수들
|
||||
|
||||
```typescript
|
||||
// V2 레이아웃 복제 (공통)
|
||||
async copyLayoutV2(
|
||||
sourceScreenId: number,
|
||||
targetScreenId: number,
|
||||
targetCompanyCode: string,
|
||||
mappings: {
|
||||
componentIdMap: Map<string, string>;
|
||||
flowIdMap: Map<number, number>;
|
||||
ruleIdMap: Map<string, string>;
|
||||
screenIdMap: Map<number, number>;
|
||||
menuIdMap?: Map<number, number>;
|
||||
},
|
||||
client: PoolClient
|
||||
): Promise<void>
|
||||
|
||||
// V2 JSONB에서 참조 ID 수집
|
||||
collectReferencesFromLayoutV2(layoutData: any): {
|
||||
flowIds: Set<number>;
|
||||
ruleIds: Set<string>;
|
||||
screenIds: Set<number>;
|
||||
}
|
||||
|
||||
// V2 JSONB 내 참조 업데이트
|
||||
updateReferencesInLayoutV2(
|
||||
layoutData: any,
|
||||
mappings: { ... }
|
||||
): any
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 마이그레이션 전략
|
||||
|
||||
### 4.1 전략: V2 완전 전환
|
||||
|
||||
```
|
||||
결정: V2만 복제 (Legacy 복제 제거)
|
||||
이유: 깔끔한 코드, 유지보수 용이, V2 아키텍처 일관성
|
||||
전제: 기존 화면들은 이미 screen_layouts_v2로 마이그레이션 완료 (1,347개 100%)
|
||||
```
|
||||
|
||||
### 4.2 단계별 계획
|
||||
|
||||
#### Phase 1: V2 복제 로직 구현 및 전환
|
||||
|
||||
```
|
||||
목표: Legacy 복제를 V2 복제로 완전 교체
|
||||
영향: 복제 시 screen_layouts_v2 테이블만 사용
|
||||
|
||||
작업:
|
||||
1. copyLayoutV2() 공통 함수 구현
|
||||
2. screenManagementService.copyScreen() - Legacy → V2 교체
|
||||
3. menuCopyService.copyScreens() - Legacy → V2 교체
|
||||
4. 테스트 및 검증
|
||||
```
|
||||
|
||||
#### Phase 2: Legacy 코드 정리
|
||||
|
||||
```
|
||||
목표: 불필요한 Legacy 복제 코드 제거
|
||||
영향: 코드 간소화
|
||||
|
||||
작업:
|
||||
1. screen_layouts 관련 복제 코드 제거
|
||||
2. 관련 헬퍼 함수 정리 (collectFlowIdsFromLayouts 등)
|
||||
3. 코드 리뷰 및 정리
|
||||
```
|
||||
|
||||
#### Phase 3: Legacy 테이블 정리 (선택, 추후)
|
||||
|
||||
```
|
||||
목표: 불필요한 테이블 제거
|
||||
영향: 데이터 정리
|
||||
|
||||
작업:
|
||||
1. screen_layouts 테이블 데이터 백업
|
||||
2. screen_layouts 테이블 삭제 (또는 보관)
|
||||
3. 관련 코드 정리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 상세 구현 계획
|
||||
|
||||
### 5.1 Phase 1 작업 목록
|
||||
|
||||
| # | 작업 | 파일 | 예상 공수 |
|
||||
|---|-----|------|---------|
|
||||
| 1 | `copyLayoutV2()` 공통 함수 구현 | screenManagementService.ts | 2시간 |
|
||||
| 2 | `collectReferencesFromLayoutV2()` 구현 | screenManagementService.ts | 1시간 |
|
||||
| 3 | `updateReferencesInLayoutV2()` 구현 | screenManagementService.ts | 2시간 |
|
||||
| 4 | `copyScreen()` - Legacy 제거, V2로 교체 | screenManagementService.ts | 2시간 |
|
||||
| 5 | `copyScreens()` - Legacy 제거, V2로 교체 | menuCopyService.ts | 3시간 |
|
||||
| 6 | 단위 테스트 | - | 2시간 |
|
||||
| 7 | 통합 테스트 | - | 2시간 |
|
||||
|
||||
**총 예상 공수: 14시간 (약 2일)**
|
||||
|
||||
### 5.2 주요 변경 포인트
|
||||
|
||||
#### copyScreen() 변경 전후
|
||||
|
||||
**Before (Legacy):**
|
||||
```typescript
|
||||
// 4. 원본 화면의 레이아웃 정보 조회
|
||||
const sourceLayoutsResult = await client.query<any>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
||||
[sourceScreenId]
|
||||
);
|
||||
// ... N개 레코드 순회하며 INSERT
|
||||
```
|
||||
|
||||
**After (V2):**
|
||||
```typescript
|
||||
// 4. 원본 V2 레이아웃 조회
|
||||
const sourceLayoutV2 = await client.query(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[sourceScreenId, sourceCompanyCode]
|
||||
);
|
||||
// ... JSONB 변환 후 1개 레코드 INSERT
|
||||
```
|
||||
|
||||
#### copyScreens() 변경 전후
|
||||
|
||||
**Before (Legacy):**
|
||||
```typescript
|
||||
// 레이아웃 배치 INSERT
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`,
|
||||
layoutParams
|
||||
);
|
||||
```
|
||||
|
||||
**After (V2):**
|
||||
```typescript
|
||||
// V2 레이아웃 UPSERT
|
||||
await this.copyLayoutV2(
|
||||
originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode,
|
||||
{ componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap },
|
||||
client
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 copyLayoutV2() 구현 방안
|
||||
|
||||
```typescript
|
||||
private async copyLayoutV2(
|
||||
sourceScreenId: number,
|
||||
targetScreenId: number,
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
mappings: {
|
||||
componentIdMap: Map<string, string>;
|
||||
flowIdMap?: Map<number, number>;
|
||||
ruleIdMap?: Map<string, string>;
|
||||
screenIdMap?: Map<number, number>;
|
||||
menuIdMap?: Map<number, number>;
|
||||
},
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
// 1. 원본 V2 레이아웃 조회
|
||||
const sourceResult = await client.query(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[sourceScreenId, sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (sourceResult.rows.length === 0) {
|
||||
// V2 레이아웃 없으면 스킵 (Legacy만 있는 경우)
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutData = sourceResult.rows[0].layout_data;
|
||||
|
||||
// 2. components 배열 순회하며 ID 매핑
|
||||
const updatedComponents = layoutData.components.map((comp: any) => {
|
||||
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
|
||||
|
||||
// overrides 내 참조 업데이트
|
||||
let updatedOverrides = { ...comp.overrides };
|
||||
|
||||
// flowId 매핑
|
||||
if (mappings.flowIdMap && updatedOverrides.flowId) {
|
||||
const newFlowId = mappings.flowIdMap.get(updatedOverrides.flowId);
|
||||
if (newFlowId) updatedOverrides.flowId = newFlowId;
|
||||
}
|
||||
|
||||
// numberingRuleId 매핑
|
||||
if (mappings.ruleIdMap && updatedOverrides.numberingRuleId) {
|
||||
const newRuleId = mappings.ruleIdMap.get(updatedOverrides.numberingRuleId);
|
||||
if (newRuleId) updatedOverrides.numberingRuleId = newRuleId;
|
||||
}
|
||||
|
||||
// screenId 매핑 (탭 컴포넌트 등)
|
||||
if (mappings.screenIdMap && updatedOverrides.screenId) {
|
||||
const newScreenId = mappings.screenIdMap.get(updatedOverrides.screenId);
|
||||
if (newScreenId) updatedOverrides.screenId = newScreenId;
|
||||
}
|
||||
|
||||
// tabs 배열 내 screenId 매핑
|
||||
if (mappings.screenIdMap && Array.isArray(updatedOverrides.tabs)) {
|
||||
updatedOverrides.tabs = updatedOverrides.tabs.map((tab: any) => ({
|
||||
...tab,
|
||||
screenId: mappings.screenIdMap.get(tab.screenId) || tab.screenId
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
...comp,
|
||||
id: newId,
|
||||
overrides: updatedOverrides
|
||||
};
|
||||
});
|
||||
|
||||
const newLayoutData = {
|
||||
...layoutData,
|
||||
components: updatedComponents,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 3. 대상 V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[targetScreenId, targetCompanyCode, JSON.stringify(newLayoutData)]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 계획
|
||||
|
||||
### 6.1 단위 테스트
|
||||
|
||||
| 테스트 케이스 | 설명 |
|
||||
|-------------|------|
|
||||
| V2 레이아웃 복제 - 기본 | 단순 컴포넌트 복제 |
|
||||
| V2 레이아웃 복제 - flowId 매핑 | 회사 간 복제 시 flowId 변경 확인 |
|
||||
| V2 레이아웃 복제 - ruleId 매핑 | 회사 간 복제 시 ruleId 변경 확인 |
|
||||
| V2 레이아웃 복제 - 탭 screenId 매핑 | 탭 컴포넌트의 screenId 변경 확인 |
|
||||
| V2 레이아웃 없는 경우 | Legacy만 있는 화면 복제 시 스킵 확인 |
|
||||
|
||||
### 6.2 통합 테스트
|
||||
|
||||
| 테스트 케이스 | 설명 |
|
||||
|-------------|------|
|
||||
| 단일 화면 복제 (같은 회사) | copyScreen() - 동일 회사 내 복제 |
|
||||
| 단일 화면 복제 (다른 회사) | copyScreen() - 회사 간 복제 |
|
||||
| 메뉴 일괄 복제 | copyScreens() - 여러 화면 동시 복제 |
|
||||
| 모달 포함 복제 | copyScreenWithModals() - 메인 + 모달 복제 |
|
||||
|
||||
### 6.3 검증 항목
|
||||
|
||||
```
|
||||
복제 후 확인:
|
||||
- [ ] screen_layouts_v2에 레코드 생성됨
|
||||
- [ ] componentId가 새로 생성됨
|
||||
- [ ] flowId가 정확히 매핑됨
|
||||
- [ ] numberingRuleId가 정확히 매핑됨
|
||||
- [ ] 탭 컴포넌트의 screenId가 정확히 매핑됨
|
||||
- [ ] screen_layouts(Legacy)는 복제되지 않음
|
||||
- [ ] 복제된 화면이 프론트엔드에서 정상 로드됨
|
||||
- [ ] 복제된 화면 편집/저장 정상 동작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 영향 분석
|
||||
|
||||
### 7.1 영향 받는 기능
|
||||
|
||||
| 기능 | 영향 | 비고 |
|
||||
|-----|-----|-----|
|
||||
| 화면 관리 - 화면 복제 | 직접 영향 | copyScreen() |
|
||||
| 화면 관리 - 그룹 복제 | 직접 영향 | copyScreenWithModals() |
|
||||
| 메뉴 복제 | 직접 영향 | menuCopyService.copyScreens() |
|
||||
| 화면 디자이너 | 간접 영향 | 복제된 화면 로드 시 V2 사용 |
|
||||
|
||||
### 7.2 롤백 계획
|
||||
|
||||
```
|
||||
V2 전환 롤백 (필요시):
|
||||
1. Git에서 이전 버전 복원 (copyScreen, copyScreens)
|
||||
2. Legacy 복제 코드 복원
|
||||
3. 테스트 후 배포
|
||||
|
||||
주의사항:
|
||||
- V2로 복제된 화면들은 screen_layouts_v2에만 데이터 존재
|
||||
- 롤백 시 해당 화면들은 screen_layouts에 데이터 없음
|
||||
- 필요시 V2 → Legacy 역변환 스크립트 실행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 파일
|
||||
|
||||
### 8.1 수정 대상
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|-----|----------|
|
||||
| `backend-node/src/services/screenManagementService.ts` | copyLayoutV2(), copyScreen() 수정 |
|
||||
| `backend-node/src/services/menuCopyService.ts` | copyScreens() 수정 |
|
||||
|
||||
### 8.2 참고 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|-----|-----|
|
||||
| `docs/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | V2 아키텍처 문서 |
|
||||
| `frontend/lib/api/screen.ts` | getLayoutV2, saveLayoutV2 |
|
||||
| `frontend/lib/utils/layoutV2Converter.ts` | V2 변환 유틸리티 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 체크리스트
|
||||
|
||||
### 9.1 개발 전
|
||||
|
||||
- [ ] V2 아키텍처 문서 숙지
|
||||
- [ ] 현재 복제 로직 코드 리뷰
|
||||
- [ ] 테스트 데이터 준비 (V2 레이아웃이 있는 화면)
|
||||
|
||||
### 9.2 Phase 1 완료 조건
|
||||
|
||||
- [x] copyLayoutV2() 함수 구현 ✅ 2026-01-28
|
||||
- [x] collectReferencesFromLayoutV2() 함수 구현 ✅ 2026-01-28
|
||||
- [x] updateReferencesInLayoutV2() 함수 구현 ✅ 2026-01-28
|
||||
- [x] copyScreen() - Legacy 제거, V2로 교체 ✅ 2026-01-28
|
||||
- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28
|
||||
- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28
|
||||
- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28
|
||||
- [ ] 단위 테스트 통과
|
||||
- [ ] 통합 테스트 통과
|
||||
- [ ] V2 전용 복제 동작 확인
|
||||
|
||||
### 9.3 Phase 2 완료 조건
|
||||
|
||||
- [ ] Legacy 관련 헬퍼 함수 정리
|
||||
- [ ] 불필요한 코드 제거
|
||||
- [ ] 코드 리뷰 완료
|
||||
- [ ] 회귀 테스트 통과
|
||||
|
||||
---
|
||||
|
||||
## 10. 시뮬레이션 검증 결과
|
||||
|
||||
### 10.1 검증된 시나리오
|
||||
|
||||
| 시나리오 | 결과 | 비고 |
|
||||
|---------|------|------|
|
||||
| 같은 회사 내 복제 | ✅ 정상 | componentId만 새로 생성 |
|
||||
| 회사 간 복제 (flowId 매핑) | ✅ 정상 | flowIdMap 적용됨 |
|
||||
| 회사 간 복제 (ruleId 매핑) | ✅ 정상 | ruleIdMap 적용됨 |
|
||||
| 탭 컴포넌트 screenId 매핑 | ✅ 정상 | updateTabScreenReferences V2 지원 추가 |
|
||||
| V2 레이아웃 없는 화면 | ✅ 정상 | 스킵 처리 |
|
||||
|
||||
### 10.2 발견 및 수정된 문제
|
||||
|
||||
| 문제 | 해결 |
|
||||
|-----|------|
|
||||
| updateTabScreenReferences가 V2 미지원 | V2 처리 로직 추가 완료 |
|
||||
|
||||
### 10.3 Zod 활용 가능성
|
||||
|
||||
프론트엔드에 이미 훌륭한 Zod 유틸리티 존재:
|
||||
- `deepMerge()` - 깊은 병합
|
||||
- `extractCustomConfig()` - 차이값 추출
|
||||
- `loadComponentV2()` / `saveComponentV2()` - V2 로드/저장
|
||||
|
||||
향후 백엔드에도 Zod 추가 시:
|
||||
- 타입 안전성 향상
|
||||
- 프론트/백엔드 스키마 공유 가능
|
||||
- 범용 참조 탐색 로직으로 하드코딩 제거 가능
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 | 작성자 |
|
||||
|-----|----------|-------|
|
||||
| 2026-01-28 | 초안 작성 | Claude |
|
||||
| 2026-01-28 | V2 완전 전환 전략으로 변경 (병행 운영 → V2 전용) | Claude |
|
||||
| 2026-01-28 | Phase 1 구현 완료 - V2 복제 함수들 구현 및 Legacy 교체 | Claude |
|
||||
| 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude |
|
||||
| 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude |
|
||||
| 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude |
|
||||
356
docs/V2_COMPONENT_MIGRATION_ANALYSIS.md
Normal file
356
docs/V2_COMPONENT_MIGRATION_ANALYSIS.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# V2 컴포넌트 마이그레이션 분석 보고서
|
||||
|
||||
> 작성일: 2026-01-27
|
||||
> 목적: 미구현 V1 컴포넌트들의 V2 마이그레이션 가능성 분석
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 요약
|
||||
|
||||
| 구분 | 개수 | 비율 |
|
||||
|------|------|------|
|
||||
| V1 총 컴포넌트 | 7,170개 | 100% |
|
||||
| V2 마이그레이션 완료 | 5,212개 | 72.7% |
|
||||
| **미구현 (분석 대상)** | **~520개** | **7.3%** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 미구현 컴포넌트 상세 분석
|
||||
|
||||
### 2.1 ✅ 통합 가능 (기존 V2 컴포넌트로 대체)
|
||||
|
||||
#### 2.1.1 `unified-list` (97개) → `v2-table-list`
|
||||
|
||||
**분석 결과**: ✅ **통합 가능**
|
||||
|
||||
| 항목 | unified-list | v2-table-list |
|
||||
|------|-------------|---------------|
|
||||
| 테이블 뷰 | ✅ | ✅ |
|
||||
| 카드 뷰 | ✅ | ❌ (추가 필요) |
|
||||
| 검색 | ✅ | ✅ |
|
||||
| 페이지네이션 | ✅ | ✅ |
|
||||
| 편집 가능 | ✅ | ✅ |
|
||||
|
||||
**결론**: `v2-table-list`에 `cardView` 모드만 추가하면 통합 가능. 또는 DB 마이그레이션으로 `v2-table-list`로 변환.
|
||||
|
||||
**작업량**: 중간 (v2-table-list 확장 또는 DB 마이그레이션)
|
||||
|
||||
---
|
||||
|
||||
#### 2.1.2 `autocomplete-search-input` (50개) → `v2-select`
|
||||
|
||||
**분석 결과**: ✅ **통합 가능**
|
||||
|
||||
| 항목 | autocomplete-search-input | v2-select |
|
||||
|------|--------------------------|-----------|
|
||||
| 자동완성 드롭다운 | ✅ | ✅ (mode: autocomplete) |
|
||||
| 테이블 데이터 검색 | ✅ | ✅ (dataSource 설정) |
|
||||
| 표시/값 필드 분리 | ✅ | ✅ |
|
||||
|
||||
**결론**: `v2-select`의 `mode: "autocomplete"` 또는 `mode: "combobox"`로 대체 가능.
|
||||
|
||||
**작업량**: 낮음 (DB 마이그레이션만)
|
||||
|
||||
---
|
||||
|
||||
#### 2.1.3 `repeater-field-group` (24개) → `v2-repeater`
|
||||
|
||||
**분석 결과**: ✅ **통합 가능**
|
||||
|
||||
`v2-repeater`가 이미 다음을 지원:
|
||||
- 인라인 테이블 모드
|
||||
- 모달 선택 모드
|
||||
- 버튼 모드
|
||||
|
||||
**결론**: `v2-repeater`의 `renderMode: "inline"`으로 대체.
|
||||
|
||||
**작업량**: 낮음 (DB 마이그레이션만)
|
||||
|
||||
---
|
||||
|
||||
#### 2.1.4 `simple-repeater-table` (1개) → `v2-repeater`
|
||||
|
||||
**분석 결과**: ✅ **통합 가능**
|
||||
|
||||
**결론**: `v2-repeater`로 대체.
|
||||
|
||||
**작업량**: 매우 낮음
|
||||
|
||||
---
|
||||
|
||||
### 2.2 ⚠️ Renderer 추가만 필요 (코드 구조 있음)
|
||||
|
||||
#### 2.2.1 `split-panel-layout2` (8개)
|
||||
|
||||
**분석 결과**: ⚠️ **Renderer 추가 필요**
|
||||
|
||||
- V1 Renderer: `SplitPanelLayout2Renderer.tsx` ✅ 존재
|
||||
- V2 Renderer: ❌ 없음
|
||||
- Component: `SplitPanelLayout2Component.tsx` ✅ 존재
|
||||
|
||||
**결론**: V2 형식으로 DB 마이그레이션만 하면 됨 (기존 Renderer가 `split-panel-layout2` ID로 등록됨).
|
||||
|
||||
**작업량**: 매우 낮음 (DB 마이그레이션만)
|
||||
|
||||
---
|
||||
|
||||
#### 2.2.2 `repeat-screen-modal` (7개)
|
||||
|
||||
**분석 결과**: ⚠️ **Renderer 추가 필요**
|
||||
|
||||
- V1 Renderer: `RepeatScreenModalRenderer.tsx` ✅ 존재
|
||||
- 정의: `hidden: true` (v2-repeat-screen-modal 사용으로 패널에서 숨김)
|
||||
|
||||
**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만.
|
||||
|
||||
**작업량**: 매우 낮음
|
||||
|
||||
---
|
||||
|
||||
#### 2.2.3 `related-data-buttons` (5개)
|
||||
|
||||
**분석 결과**: ⚠️ **Renderer 추가 필요**
|
||||
|
||||
- V1 Renderer: `RelatedDataButtonsRenderer.tsx` ✅ 존재
|
||||
- Component: `RelatedDataButtonsComponent.tsx` ✅ 존재
|
||||
|
||||
**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만.
|
||||
|
||||
**작업량**: 매우 낮음
|
||||
|
||||
---
|
||||
|
||||
### 2.3 ❌ 별도 V2 개발 필요 (복잡한 구조)
|
||||
|
||||
#### 2.3.1 `entity-search-input` (99개)
|
||||
|
||||
**분석 결과**: ❌ **별도 개발 필요**
|
||||
|
||||
**특징**:
|
||||
```typescript
|
||||
// 모달 기반 엔티티 검색
|
||||
- 테이블 선택 (tableName)
|
||||
- 검색 필드 설정 (searchFields)
|
||||
- 모달 팝업 (modalTitle, modalColumns)
|
||||
- 값/표시 필드 분리 (valueField, displayField)
|
||||
- 추가 정보 표시 (additionalFields)
|
||||
```
|
||||
|
||||
**복잡도 요인**:
|
||||
1. 모달 검색 UI가 필요
|
||||
2. 다양한 테이블 연동
|
||||
3. 추가 필드 연계 로직
|
||||
|
||||
**권장 방안**:
|
||||
- `v2-entity-search` 새로 개발
|
||||
- 또는 `v2-select`에 `mode: "entity"` 추가
|
||||
|
||||
**작업량**: 높음 (1-2일)
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.2 `modal-repeater-table` (68개)
|
||||
|
||||
**분석 결과**: ❌ **별도 개발 필요**
|
||||
|
||||
**특징**:
|
||||
```typescript
|
||||
// 모달에서 항목 검색 + 동적 테이블
|
||||
- 소스 테이블 (sourceTable, sourceColumns)
|
||||
- 모달 검색 (modalTitle, modalButtonText, multiSelect)
|
||||
- 동적 컬럼 추가 (columns)
|
||||
- 계산 규칙 (calculationRules)
|
||||
- 고유 필드 (uniqueField)
|
||||
```
|
||||
|
||||
**복잡도 요인**:
|
||||
1. 모달 검색 + 선택
|
||||
2. 동적 테이블 행 추가/삭제
|
||||
3. 계산 규칙 (단가 × 수량 = 금액)
|
||||
4. 중복 방지 로직
|
||||
|
||||
**권장 방안**:
|
||||
- `v2-repeater`의 `modal` 모드 확장
|
||||
- `ItemSelectionModal` + `RepeaterTable` 재사용
|
||||
|
||||
**작업량**: 중간 (v2-repeater가 이미 기반 제공)
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.3 `selected-items-detail-input` (83개)
|
||||
|
||||
**분석 결과**: ❌ **별도 개발 필요**
|
||||
|
||||
**특징**:
|
||||
```typescript
|
||||
// 선택된 항목들의 상세 입력
|
||||
- 데이터 소스 (dataSourceId)
|
||||
- 표시 컬럼 (displayColumns)
|
||||
- 추가 입력 필드 (additionalFields)
|
||||
- 타겟 테이블 (targetTable)
|
||||
- 레이아웃 (grid/table)
|
||||
```
|
||||
|
||||
**복잡도 요인**:
|
||||
1. 부모 컴포넌트에서 데이터 수신
|
||||
2. 동적 필드 생성
|
||||
3. 다중 테이블 저장
|
||||
|
||||
**권장 방안**:
|
||||
- `v2-selected-items-detail` 새로 개발
|
||||
- 또는 `v2-repeater`에 `mode: "detail-input"` 추가
|
||||
|
||||
**작업량**: 중간~높음
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.4 `conditional-container` (53개)
|
||||
|
||||
**분석 결과**: ❌ **별도 개발 필요**
|
||||
|
||||
**특징**:
|
||||
```typescript
|
||||
// 조건부 UI 분기
|
||||
- 제어 필드 (controlField, controlLabel)
|
||||
- 조건별 섹션 (sections: [{condition, label, screenId}])
|
||||
- 기본값 (defaultValue)
|
||||
```
|
||||
|
||||
**복잡도 요인**:
|
||||
1. 셀렉트박스 값에 따른 동적 UI 변경
|
||||
2. 화면 임베딩 (screenId)
|
||||
3. 상태 관리 복잡
|
||||
|
||||
**권장 방안**:
|
||||
- `v2-conditional-container` 새로 개발
|
||||
- 조건부 렌더링 + 화면 임베딩 로직
|
||||
|
||||
**작업량**: 높음
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.5 `universal-form-modal` (26개)
|
||||
|
||||
**분석 결과**: ❌ **별도 개발 필요**
|
||||
|
||||
**특징**:
|
||||
```typescript
|
||||
// 범용 폼 모달
|
||||
- 섹션 기반 레이아웃
|
||||
- 반복 섹션
|
||||
- 채번규칙 연동
|
||||
- 다중 테이블 저장
|
||||
```
|
||||
|
||||
**복잡도 요인**:
|
||||
1. 동적 섹션 구성
|
||||
2. 채번규칙 연동
|
||||
3. 다중 테이블 저장
|
||||
4. 반복 필드 그룹
|
||||
|
||||
**권장 방안**:
|
||||
- `v2-universal-form` 새로 개발
|
||||
- 또는 기존 컴포넌트 유지 (특수 목적)
|
||||
|
||||
**작업량**: 매우 높음 (3일 이상)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 🟢 V1 유지 권장 (특수 목적)
|
||||
|
||||
| 컴포넌트 | 개수 | 이유 |
|
||||
|----------|------|------|
|
||||
| `tax-invoice-list` | 1 | 세금계산서 전용, 재사용 낮음 |
|
||||
| `mail-recipient-selector` | 1 | 메일 전용, 재사용 낮음 |
|
||||
| `unified-select` | 5 | → v2-select로 이미 마이그레이션 |
|
||||
| `unified-date` | 2 | → v2-date로 이미 마이그레이션 |
|
||||
| `unified-repeater` | 2 | → v2-repeater로 이미 마이그레이션 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 마이그레이션 우선순위 권장
|
||||
|
||||
### 3.1 즉시 처리 (1일 이내)
|
||||
|
||||
| 순위 | 컴포넌트 | 개수 | 작업 |
|
||||
|------|----------|------|------|
|
||||
| 1 | `split-panel-layout2` | 8 | DB 마이그레이션만 |
|
||||
| 2 | `repeat-screen-modal` | 7 | DB 마이그레이션만 |
|
||||
| 3 | `related-data-buttons` | 5 | DB 마이그레이션만 |
|
||||
| 4 | `autocomplete-search-input` | 50 | → v2-select 변환 |
|
||||
| 5 | `repeater-field-group` | 24 | → v2-repeater 변환 |
|
||||
|
||||
**총: 94개 컴포넌트**
|
||||
|
||||
---
|
||||
|
||||
### 3.2 단기 처리 (1주 이내)
|
||||
|
||||
| 순위 | 컴포넌트 | 개수 | 작업 |
|
||||
|------|----------|------|------|
|
||||
| 1 | `unified-list` | 97 | → v2-table-list 확장 또는 변환 |
|
||||
| 2 | `modal-repeater-table` | 68 | v2-repeater modal 모드 확장 |
|
||||
|
||||
**총: 165개 컴포넌트**
|
||||
|
||||
---
|
||||
|
||||
### 3.3 중기 처리 (2주 이상)
|
||||
|
||||
| 순위 | 컴포넌트 | 개수 | 작업 |
|
||||
|------|----------|------|------|
|
||||
| 1 | `entity-search-input` | 99 | v2-entity-search 신규 개발 |
|
||||
| 2 | `selected-items-detail-input` | 83 | v2-selected-items-detail 개발 |
|
||||
| 3 | `conditional-container` | 53 | v2-conditional-container 개발 |
|
||||
| 4 | `universal-form-modal` | 26 | v2-universal-form 개발 |
|
||||
|
||||
**총: 261개 컴포넌트**
|
||||
|
||||
---
|
||||
|
||||
## 4. 권장 아키텍처
|
||||
|
||||
### 4.1 V2 컴포넌트 통합 계획
|
||||
|
||||
```
|
||||
v2-input ← text-input, number-input, textarea, unified-input ✅ 완료
|
||||
v2-select ← select-basic, checkbox, radio, autocomplete ⚠️ 진행중
|
||||
v2-date ← date-input, unified-date ✅ 완료
|
||||
v2-media ← file-upload, image-widget ✅ 완료
|
||||
v2-table-list ← table-list, unified-list ⚠️ 확장 필요
|
||||
v2-repeater ← repeater-field-group, modal-repeater-table,
|
||||
simple-repeater-table, related-data-buttons ⚠️ 진행중
|
||||
v2-entity-search ← entity-search-input (신규 개발 필요)
|
||||
v2-conditional ← conditional-container (신규 개발 필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 결론
|
||||
|
||||
### 즉시 처리 가능 (Renderer/DB만)
|
||||
- `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons`: **20개**
|
||||
- `autocomplete-search-input` → `v2-select`: **50개**
|
||||
- `repeater-field-group` → `v2-repeater`: **24개**
|
||||
|
||||
### 통합 검토 필요
|
||||
- `unified-list` → `v2-table-list` 확장: **97개**
|
||||
- `modal-repeater-table` → `v2-repeater` 확장: **68개**
|
||||
|
||||
### 신규 개발 필요
|
||||
- `entity-search-input`: **99개** (복잡도 높음)
|
||||
- `selected-items-detail-input`: **83개**
|
||||
- `conditional-container`: **53개**
|
||||
- `universal-form-modal`: **26개**
|
||||
|
||||
### 유지
|
||||
- 특수 목적 컴포넌트: **3개** (tax-invoice-list, mail-recipient-selector)
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 단계
|
||||
|
||||
1. **즉시**: `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons` DB 마이그레이션
|
||||
2. **이번 주**: `autocomplete-search-input` → `v2-select`, `repeater-field-group` → `v2-repeater` 변환
|
||||
3. **다음 주**: `unified-list`, `modal-repeater-table` 통합 설계
|
||||
4. **이후**: `entity-search-input`, `conditional-container` 신규 개발 계획 수립
|
||||
@@ -597,7 +597,7 @@ export default function CopyScreenModal({
|
||||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: "MAIN",
|
||||
display_order: 1,
|
||||
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
|
||||
target_company_code: targetCompanyCode || sourceScreen.companyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`);
|
||||
} catch (groupError) {
|
||||
@@ -606,8 +606,68 @@ export default function CopyScreenModal({
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 복사 옵션 처리 (단일 화면 복제용)
|
||||
const sourceCompanyCode = sourceScreen.companyCode;
|
||||
const copyTargetCompanyCode = targetCompanyCode || sourceCompanyCode;
|
||||
let additionalCopyMessages: string[] = [];
|
||||
|
||||
// 채번규칙 복제
|
||||
if (copyNumberingRules && sourceCompanyCode !== copyTargetCompanyCode) {
|
||||
try {
|
||||
console.log("📋 단일 화면: 채번규칙 복제 시작...");
|
||||
const numberingResult = await apiClient.post("/api/screen-management/copy-numbering-rules", {
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode: copyTargetCompanyCode
|
||||
});
|
||||
if (numberingResult.data.success) {
|
||||
additionalCopyMessages.push(`채번규칙 ${numberingResult.data.copiedCount || 0}개`);
|
||||
console.log("✅ 채번규칙 복제 완료:", numberingResult.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("채번규칙 복제 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 값 복제
|
||||
if (copyCategoryValues && sourceCompanyCode !== copyTargetCompanyCode) {
|
||||
try {
|
||||
console.log("📋 단일 화면: 카테고리 값 복제 시작...");
|
||||
const categoryResult = await apiClient.post("/api/screen-management/copy-category-mapping", {
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode: copyTargetCompanyCode
|
||||
});
|
||||
if (categoryResult.data.success) {
|
||||
additionalCopyMessages.push(`카테고리 값 ${categoryResult.data.copiedValues || 0}개`);
|
||||
console.log("✅ 카테고리 값 복제 완료:", categoryResult.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("카테고리 값 복제 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 타입 컬럼 복제
|
||||
if (copyTableTypeColumns && sourceCompanyCode !== copyTargetCompanyCode) {
|
||||
try {
|
||||
console.log("📋 단일 화면: 테이블 타입 컬럼 복제 시작...");
|
||||
const tableTypeResult = await apiClient.post("/api/screen-management/copy-table-type-columns", {
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode: copyTargetCompanyCode
|
||||
});
|
||||
if (tableTypeResult.data.success) {
|
||||
additionalCopyMessages.push(`테이블 타입 컬럼 ${tableTypeResult.data.copiedCount || 0}개`);
|
||||
console.log("✅ 테이블 타입 컬럼 복제 완료:", tableTypeResult.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("테이블 타입 컬럼 복제 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const additionalInfo = additionalCopyMessages.length > 0
|
||||
? ` + 추가: ${additionalCopyMessages.join(", ")}`
|
||||
: "";
|
||||
|
||||
toast.success(
|
||||
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)`
|
||||
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개${additionalInfo})`
|
||||
);
|
||||
|
||||
// 새로고침 완료 후 모달 닫기
|
||||
@@ -1678,6 +1738,50 @@ export default function CopyScreenModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 복사 옵션 (단일 화면 복제용) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm font-medium">추가 복사 옵션 (선택사항):</Label>
|
||||
|
||||
{/* 채번규칙 복제 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyNumberingRulesScreen"
|
||||
checked={copyNumberingRules}
|
||||
onCheckedChange={(checked) => setCopyNumberingRules(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyNumberingRulesScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
채번 규칙 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 값 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCategoryValuesScreen"
|
||||
checked={copyCategoryValues}
|
||||
onCheckedChange={(checked) => setCopyCategoryValues(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCategoryValuesScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Table className="h-4 w-4 text-muted-foreground" />
|
||||
카테고리 값 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 테이블 타입관리 입력타입 설정 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyTableTypeColumnsScreen"
|
||||
checked={copyTableTypeColumns}
|
||||
onCheckedChange={(checked) => setCopyTableTypeColumns(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyTableTypeColumnsScreen" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
테이블 타입관리 입력타입 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화면명 일괄 수정 (접히는 옵션) */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
|
||||
@@ -175,7 +175,7 @@ export function ScreenGroupTreeView({
|
||||
const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null);
|
||||
|
||||
// 회사 선택 (최고 관리자용)
|
||||
const { user, switchCompany } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
|
||||
const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false);
|
||||
@@ -301,23 +301,18 @@ export function ScreenGroupTreeView({
|
||||
}
|
||||
};
|
||||
|
||||
// 회사 선택 시 회사 전환 + 상태 조회
|
||||
// 회사 선택 시 상태만 변경 (페이지 새로고침 없이)
|
||||
const handleCompanySelect = async (companyCode: string) => {
|
||||
setSelectedCompanyCode(companyCode);
|
||||
setIsSyncCompanySelectOpen(false);
|
||||
setSyncStatus(null);
|
||||
|
||||
if (companyCode) {
|
||||
// 🔧 회사 전환 (JWT 토큰 변경) - 모든 API가 선택한 회사로 동작하도록
|
||||
const switchResult = await switchCompany(companyCode);
|
||||
if (!switchResult.success) {
|
||||
toast.error(switchResult.message || "회사 전환 실패");
|
||||
return;
|
||||
// 동기화 상태 조회 (선택한 회사 코드로)
|
||||
const response = await getMenuScreenSyncStatus(companyCode);
|
||||
if (response.success && response.data) {
|
||||
setSyncStatus(response.data);
|
||||
}
|
||||
toast.success(`${companyCode} 회사로 전환되었습니다. 페이지를 새로고침합니다.`);
|
||||
|
||||
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -447,17 +442,24 @@ export function ScreenGroupTreeView({
|
||||
};
|
||||
|
||||
// 그룹과 모든 하위 그룹의 화면을 재귀적으로 수집
|
||||
const getAllScreensInGroupRecursively = (groupId: number): ScreenDefinition[] => {
|
||||
// 같은 회사의 그룹만 필터링하여 다른 회사 화면이 잘못 수집되는 것을 방지
|
||||
const getAllScreensInGroupRecursively = (groupId: number, targetCompanyCode?: string): ScreenDefinition[] => {
|
||||
const result: ScreenDefinition[] = [];
|
||||
// 부모 그룹의 company_code 확인
|
||||
const parentGroup = groups.find(g => g.id === groupId);
|
||||
const companyCode = targetCompanyCode || parentGroup?.company_code;
|
||||
|
||||
// 현재 그룹의 화면들
|
||||
const currentGroupScreens = getScreensInGroup(groupId);
|
||||
result.push(...currentGroupScreens);
|
||||
|
||||
// 하위 그룹들 찾기
|
||||
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
|
||||
// 같은 회사 + 같은 부모를 가진 하위 그룹들 찾기
|
||||
const childGroups = groups.filter((g) =>
|
||||
(g as any).parent_group_id === groupId &&
|
||||
(!companyCode || g.company_code === companyCode)
|
||||
);
|
||||
for (const childGroup of childGroups) {
|
||||
const childScreens = getAllScreensInGroupRecursively(childGroup.id);
|
||||
const childScreens = getAllScreensInGroupRecursively(childGroup.id, companyCode);
|
||||
result.push(...childScreens);
|
||||
}
|
||||
|
||||
@@ -465,13 +467,22 @@ export function ScreenGroupTreeView({
|
||||
};
|
||||
|
||||
// 모든 하위 그룹 ID를 재귀적으로 수집 (삭제 순서: 자식 → 부모)
|
||||
const getAllChildGroupIds = (groupId: number): number[] => {
|
||||
// 같은 회사의 그룹만 필터링하여 다른 회사 그룹이 잘못 삭제되는 것을 방지
|
||||
const getAllChildGroupIds = (groupId: number, targetCompanyCode?: string): number[] => {
|
||||
const result: number[] = [];
|
||||
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
|
||||
// 부모 그룹의 company_code 확인
|
||||
const parentGroup = groups.find(g => g.id === groupId);
|
||||
const companyCode = targetCompanyCode || parentGroup?.company_code;
|
||||
|
||||
// 같은 회사 + 같은 부모를 가진 그룹만 필터링
|
||||
const childGroups = groups.filter((g) =>
|
||||
(g as any).parent_group_id === groupId &&
|
||||
(!companyCode || g.company_code === companyCode)
|
||||
);
|
||||
|
||||
for (const childGroup of childGroups) {
|
||||
// 자식의 자식들을 먼저 수집 (깊은 곳부터)
|
||||
const grandChildIds = getAllChildGroupIds(childGroup.id);
|
||||
const grandChildIds = getAllChildGroupIds(childGroup.id, companyCode);
|
||||
result.push(...grandChildIds);
|
||||
result.push(childGroup.id);
|
||||
}
|
||||
@@ -483,10 +494,35 @@ export function ScreenGroupTreeView({
|
||||
const confirmDeleteGroup = async () => {
|
||||
if (!deletingGroup) return;
|
||||
|
||||
// 🔍 디버깅: 삭제 대상 그룹 정보
|
||||
console.log("========== 그룹 삭제 디버깅 ==========");
|
||||
console.log("삭제 대상 그룹:", {
|
||||
id: deletingGroup.id,
|
||||
name: deletingGroup.group_name,
|
||||
company_code: deletingGroup.company_code,
|
||||
parent_group_id: (deletingGroup as any).parent_group_id
|
||||
});
|
||||
|
||||
// 🔍 디버깅: 전체 groups 배열에서 같은 회사 그룹 출력
|
||||
const sameCompanyGroups = groups.filter(g => g.company_code === deletingGroup.company_code);
|
||||
console.log("같은 회사 그룹들:", sameCompanyGroups.map(g => ({
|
||||
id: g.id,
|
||||
name: g.group_name,
|
||||
parent_group_id: (g as any).parent_group_id
|
||||
})));
|
||||
|
||||
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
|
||||
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
|
||||
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
|
||||
|
||||
// 🔍 디버깅: 수집된 하위 그룹 ID들
|
||||
console.log("수집된 하위 그룹 ID들:", childGroupIds);
|
||||
console.log("하위 그룹 상세:", childGroupIds.map(id => {
|
||||
const g = groups.find(grp => grp.id === id);
|
||||
return g ? { id: g.id, name: g.group_name, parent_group_id: (g as any).parent_group_id } : { id, name: "NOT_FOUND" };
|
||||
}));
|
||||
console.log("==========================================");
|
||||
|
||||
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
|
||||
const totalSteps = totalScreensToDelete + childGroupIds.length + 1;
|
||||
let currentStep = 0;
|
||||
@@ -511,7 +547,7 @@ export function ScreenGroupTreeView({
|
||||
total: totalSteps,
|
||||
message: `화면 삭제 중: ${screen.screenName}`
|
||||
});
|
||||
await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제");
|
||||
await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제", true); // force: true로 의존성 무시
|
||||
}
|
||||
console.log(`✅ 그룹 및 하위 그룹 내 화면 ${allScreens.length}개 삭제 완료`);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface CreateCategoryValueInput {
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
targetCompanyCode?: string; // 저장할 회사 코드 (최고 관리자가 회사 선택 시)
|
||||
}
|
||||
|
||||
// 카테고리 값 수정 입력
|
||||
|
||||
@@ -176,7 +176,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||
const rawComponentType = (component as any).componentType || component.type;
|
||||
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
||||
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
// url의 마지막 세그먼트를 컴포넌트 타입으로 사용
|
||||
const segments = url.split("/");
|
||||
return segments[segments.length - 1];
|
||||
};
|
||||
|
||||
const rawComponentType = (component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
|
||||
|
||||
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
|
||||
const mapToV2ComponentType = (type: string | undefined): string | undefined => {
|
||||
|
||||
@@ -105,9 +105,12 @@ import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
|
||||
import "./v2-table-search-widget";
|
||||
import "./v2-tabs-widget/tabs-component";
|
||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||
import "./v2-media"; // 통합 미디어 컴포넌트
|
||||
import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트
|
||||
import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블
|
||||
import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
|
||||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
||||
64
frontend/lib/registry/components/v2-date/V2DateRenderer.tsx
Normal file
64
frontend/lib/registry/components/v2-date/V2DateRenderer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2DateDefinition } from "./index";
|
||||
import { V2Date } from "@/components/v2/V2Date";
|
||||
|
||||
/**
|
||||
* V2Date 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2DateDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<V2Date
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
dateType: config.dateType || config.webType || "date",
|
||||
format: config.format || "YYYY-MM-DD",
|
||||
placeholder: config.placeholder || "날짜 선택",
|
||||
showTime: config.showTime || false,
|
||||
use24Hours: config.use24Hours ?? true,
|
||||
minDate: config.minDate,
|
||||
maxDate: config.maxDate,
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
V2DateRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
V2DateRenderer.enableHotReload();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2InputDefinition } from "./index";
|
||||
import { V2Input } from "@/components/v2/V2Input";
|
||||
|
||||
/**
|
||||
* V2Input 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2InputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<V2Input
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
type: config.inputType || config.webType || "text",
|
||||
inputType: config.inputType || config.webType || "text",
|
||||
placeholder: config.placeholder,
|
||||
format: config.format,
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
step: config.step,
|
||||
rows: config.rows,
|
||||
autoGeneration: config.autoGeneration || component.autoGeneration,
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
formData={formData}
|
||||
columnName={columnName}
|
||||
tableName={tableName}
|
||||
autoGeneration={config.autoGeneration || component.autoGeneration}
|
||||
originalData={(this.props as any).originalData}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
V2InputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
V2InputRenderer.enableHotReload();
|
||||
}
|
||||
109
frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx
Normal file
109
frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2MediaDefinition } from "./index";
|
||||
import { V2Media } from "@/components/v2/V2Media";
|
||||
|
||||
/**
|
||||
* V2Media 렌더러
|
||||
* 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2MediaRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2MediaDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
// V1 file-upload, image-widget에서 넘어온 설정 매핑
|
||||
const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType);
|
||||
|
||||
// maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용)
|
||||
const maxSizeBytes = config.maxSize
|
||||
? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024)
|
||||
: 10 * 1024 * 1024; // 기본 10MB
|
||||
|
||||
return (
|
||||
<V2Media
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
type: mediaType,
|
||||
multiple: config.multiple ?? false,
|
||||
preview: config.preview ?? true,
|
||||
maxSize: maxSizeBytes,
|
||||
accept: config.accept || this.getDefaultAccept(mediaType),
|
||||
uploadEndpoint: config.uploadEndpoint || "/api/upload",
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
formData={formData}
|
||||
columnName={columnName}
|
||||
tableName={tableName}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* webType에서 미디어 타입 추출
|
||||
*/
|
||||
private getMediaTypeFromWebType(webType?: string): "file" | "image" | "video" | "audio" {
|
||||
switch (webType) {
|
||||
case "image":
|
||||
return "image";
|
||||
case "video":
|
||||
return "video";
|
||||
case "audio":
|
||||
return "audio";
|
||||
case "file":
|
||||
default:
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 타입에 따른 기본 accept 값
|
||||
*/
|
||||
private getDefaultAccept(mediaType: string): string {
|
||||
switch (mediaType) {
|
||||
case "image":
|
||||
return "image/*";
|
||||
case "video":
|
||||
return "video/*";
|
||||
case "audio":
|
||||
return "audio/*";
|
||||
default:
|
||||
return "*/*";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
V2MediaRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
V2MediaRenderer.enableHotReload();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { V2SelectDefinition } from "./index";
|
||||
import { V2Select } from "@/components/v2/V2Select";
|
||||
|
||||
/**
|
||||
* V2Select 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = V2SelectDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
const columnName = component.columnName;
|
||||
const tableName = component.tableName || this.props.tableName;
|
||||
|
||||
// formData에서 현재 값 가져오기
|
||||
const currentValue = formData?.[columnName] ?? component.value ?? "";
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = (value: any) => {
|
||||
if (isInteractive && onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<V2Select
|
||||
id={component.id}
|
||||
label={component.label}
|
||||
required={component.required}
|
||||
readonly={config.readonly || component.readonly}
|
||||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
mode: config.mode || "dropdown",
|
||||
source: config.source || "distinct",
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable ?? true,
|
||||
placeholder: config.placeholder || "선택하세요",
|
||||
options: config.options || [],
|
||||
codeGroup: config.codeGroup,
|
||||
entityTable: config.entityTable,
|
||||
entityLabelColumn: config.entityLabelColumn,
|
||||
entityValueColumn: config.entityValueColumn,
|
||||
}}
|
||||
style={component.style}
|
||||
size={component.size}
|
||||
tableName={tableName}
|
||||
columnName={columnName}
|
||||
formData={formData}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
V2SelectRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
V2SelectRenderer.enableHotReload();
|
||||
}
|
||||
Reference in New Issue
Block a user