Compare commits

...

7 Commits

Author SHA1 Message Date
kjs 4447911892 feat: 메인 테이블 데이터 자동 로드 및 autoFill 기능 개선
- 메인 테이블에서 사용자 회사 코드로 데이터를 자동으로 로드하여 폼에 채우는 기능을 추가하였습니다.
- displayColumn 파라미터를 선택적으로 변경하여, 누락 시 전체 레코드를 반환하도록 수정하였습니다.
- 엔티티 조인 테이블에 고유 접두사를 추가하여 메인 테이블과의 키 중복을 방지하였습니다.
- autoFill 설정을 복원하고 저장하는 기능을 추가하여 자동 입력 기능을 강화하였습니다.
2026-02-02 12:01:39 +09:00
kjs 0614609f2b Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-02-02 10:46:37 +09:00
kjs f959ca98bd docs: v2-timeline-scheduler 구현 완료 및 상태 업데이트
- v2-timeline-scheduler의 구현 상태를 체크리스트에 반영하였으며, 관련 문서화 작업을 완료하였습니다.
- 각 구성 요소의 구현 완료 상태를 명시하고, 향후 작업 계획을 업데이트하였습니다.
- 타임라인 스케줄러 컴포넌트를 레지스트리에 추가하여 통합하였습니다.
2026-02-02 10:46:01 +09:00
DDD1542 51492a8911 feat: 인증 미들웨어 적용 및 화면 그룹 삭제 로직 개선
- 모든 라우트에 인증 미들웨어를 적용하여 보안을 강화하였습니다.
- 화면 그룹 삭제 시 회사 코드 확인 및 권한 체크 로직을 추가하여, 다른 회사의 그룹 삭제를 방지하였습니다.
- 채번 규칙, 카테고리 값, 테이블 타입 컬럼 복제 시 같은 회사로 복제하는 경우 경고 메시지를 추가하였습니다.
- 메뉴 URL 업데이트 기능을 추가하여 복제된 화면 ID에 맞게 URL을 재매핑하도록 하였습니다.
2026-02-02 09:22:34 +09:00
DDD1542 4daa77f9a1 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-01-30 14:22:24 +09:00
DDD1542 852de0fb0e feat: V2 레이아웃 처리 개선 및 새로운 V2 레이아웃 데이터 구조 도입
- 기존 레이아웃 처리 로직을 V2 레이아웃에 맞게 수정하였습니다.
- V2 레이아웃에서 layout_data를 조회하고, 변경 여부를 확인하는 로직을 추가하였습니다.
- 레이아웃 데이터의 참조 ID 업데이트 및 flowId, numberingRuleId 수집 기능을 구현하였습니다.
- V2Media 컴포넌트를 통합하여 미디어 관련 기능을 강화하였습니다.
- 레이아웃 처리 시 V2 레이아웃의 컴포넌트 매핑 및 데이터 복사를 효율적으로 처리하도록 개선하였습니다.
2026-01-30 13:38:07 +09:00
DDD1542 5b5a0d1a23 feat: V2 레이아웃 컴포넌트 타입 추출 및 새로운 V2 컴포넌트 추가
- V2 레이아웃에서 URL을 기반으로 컴포넌트 타입을 추출하는 헬퍼 함수를 추가하였습니다.
- DynamicComponentRenderer에서 V2 레이아웃의 URL에서 컴포넌트 타입을 추출하도록 수정하였습니다.
- 새로운 V2 통합 입력, 선택, 날짜 컴포넌트를 등록하여 컴포넌트 목록을 업데이트하였습니다.
- 이를 통해 V2 컴포넌트의 일관성을 높이고, 레거시 타입과의 매핑을 개선하였습니다.
2026-01-30 10:51:33 +09:00
35 changed files with 5570 additions and 290 deletions

View File

@ -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?: {

View File

@ -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) {

View File

@ -662,14 +662,14 @@ export async function getTableRecord(
logger.info(`필터: ${filterColumn} = ${filterValue}`);
logger.info(`표시 컬럼: ${displayColumn}`);
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
if (!tableName || !filterColumn || !filterValue) {
const response: ApiResponse<null> = {
success: false,
message: "필수 파라미터가 누락되었습니다.",
error: {
code: "MISSING_PARAMETERS",
details:
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
"tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.",
},
};
res.status(400).json(response);
@ -701,9 +701,12 @@ export async function getTableRecord(
}
const record = result.data[0];
const displayValue = record[displayColumn];
// displayColumn이 "*"이거나 없으면 전체 레코드 반환
const displayValue = displayColumn && displayColumn !== "*"
? record[displayColumn]
: record;
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`);
const response: ApiResponse<{ value: any; record: any }> = {
success: true,

View File

@ -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}`);
}
/**
* + (최적화: 배치 /)
*/

View File

@ -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
});
}

View File

@ -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}`,

View 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/) - 컴포넌트 라이브러리

View 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 |

View 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` 신규 개발 계획 수립

View File

@ -531,25 +531,25 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] 레지스트리 등록
- [x] 문서화 (README.md)
#### v2-timeline-scheduler
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
- [ ] 타입 정의 완료
- [ ] 기본 구조 생성
- [ ] TimelineHeader (날짜)
- [ ] TimelineGrid (배경)
- [ ] ResourceColumn (리소스)
- [ ] ScheduleBar 기본 렌더링
- [ ] 드래그 이동
- [ ] 리사이즈
- [ ] 줌 레벨 전환
- [ ] 날짜 네비게이션
- [ ] 충돌 감지
- [ ] 가상 스크롤
- [ ] 설정 패널 구현
- [ ] API 연동
- [ ] 레지스트리 등록
- [x] 타입 정의 완료
- [x] 기본 구조 생성
- [x] TimelineHeader (날짜)
- [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본)
- [x] 리사이즈 (기본)
- [x] 줌 레벨 전환
- [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후)
- [ ] 가상 스크롤 (향후)
- [x] 설정 패널 구현
- [x] API 연동
- [x] 레지스트리 등록
- [ ] 테스트 완료
- [ ] 문서화
- [x] 문서화 (README.md)
---

View File

@ -202,7 +202,89 @@ function ScreenViewPage() {
}
}, [screenId]);
// 🆕 autoFill 자동 입력 초기화
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
useEffect(() => {
const loadMainTableData = async () => {
if (!screen || !layout || !layout.components || !companyCode) {
return;
}
const mainTableName = screen.tableName;
if (!mainTableName) {
return;
}
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some(
(comp: any) =>
comp.componentType === "table-list" ||
comp.componentType === "v2-table-list" ||
comp.widgetType === "table"
);
if (hasTableWidget) {
console.log("📋 테이블 위젯이 있어 자동 로드 건너뜀 (행 선택으로 데이터 로드)");
return;
}
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
const inputComponents = layout.components.filter((comp: any) => {
const compType = comp.componentType || comp.widgetType;
const isInputType = compType?.includes("input") ||
compType?.includes("select") ||
compType?.includes("textarea") ||
compType?.includes("v2-input") ||
compType?.includes("v2-select");
const hasColumnName = !!(comp as any).columnName;
return isInputType && hasColumnName;
});
if (inputComponents.length === 0) {
return;
}
// 메인 테이블에서 현재 회사의 데이터 조회
try {
const { tableTypeApi } = await import("@/lib/api/screen");
// company_code로 필터링하여 단일 레코드 조회
const result = await tableTypeApi.getTableRecord(
mainTableName,
"company_code",
companyCode,
"*" // 모든 컬럼
);
if (result && result.record) {
console.log("📦 메인 테이블 데이터 자동 로드:", mainTableName, result.record);
// 각 인풋 컴포넌트에 해당하는 데이터 채우기
const newFormData: Record<string, any> = {};
inputComponents.forEach((comp: any) => {
const columnName = comp.columnName;
if (columnName && result.record[columnName] !== undefined) {
newFormData[columnName] = result.record[columnName];
}
});
if (Object.keys(newFormData).length > 0) {
setFormData((prev) => ({
...prev,
...newFormData,
}));
}
}
} catch (error) {
console.log("메인 테이블 자동 로드 실패 (정상일 수 있음):", error);
// 에러는 무시 - 데이터가 없거나 권한이 없을 수 있음
}
};
loadMainTableData();
}, [screen, layout, companyCode]);
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
useEffect(() => {
const initAutoFill = async () => {
if (!layout || !layout.components || !user) {
@ -215,7 +297,7 @@ function ScreenViewPage() {
const widget = comp as any;
const fieldName = widget.columnName || widget.id;
// autoFill 처리
// autoFill 처리 (명시적으로 설정된 경우만)
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
const currentValue = formData[fieldName];

View File

@ -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">

View File

@ -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}개 삭제 완료`);
}

View File

@ -431,7 +431,8 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
}
return (
<div key={joinTable.tableName} className="space-y-1">
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"

View File

@ -41,6 +41,7 @@ export interface CreateCategoryValueInput {
icon?: string;
isActive?: boolean;
isDefault?: boolean;
targetCompanyCode?: string; // 저장할 회사 코드 (최고 관리자가 회사 선택 시)
}
// 카테고리 값 수정 입력

View File

@ -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 => {

View File

@ -105,8 +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 통합 날짜 컴포넌트
/**
*

View 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();
}

View File

@ -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();
}

View 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();
}

View File

@ -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();
}

View File

@ -45,7 +45,7 @@ import { Trash2, Plus } from "lucide-react";
interface TableGroupedConfigPanelProps {
config: TableGroupedConfig;
onConfigChange: (newConfig: TableGroupedConfig) => void;
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
}
/**
@ -59,7 +59,7 @@ interface TableInfo {
export function TableGroupedConfigPanel({
config,
onConfigChange,
onChange,
}: TableGroupedConfigPanelProps) {
// 테이블 목록 (라벨명 포함)
const [tables, setTables] = useState<TableInfo[]>([]);
@ -122,7 +122,7 @@ export function TableGroupedConfigPanel({
// 컬럼 설정이 없으면 자동 설정
if (!config.columns || config.columns.length === 0) {
onConfigChange({ ...config, columns: cols });
onChange({ ...config, columns: cols });
}
}
} catch (err) {
@ -136,14 +136,14 @@ export function TableGroupedConfigPanel({
// 설정 업데이트 헬퍼
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
onConfigChange({ ...config, ...updates });
onChange({ ...config, ...updates });
};
// 그룹 설정 업데이트 헬퍼
const updateGroupConfig = (
updates: Partial<TableGroupedConfig["groupConfig"]>
) => {
onConfigChange({
onChange({
...config,
groupConfig: { ...config.groupConfig, ...updates },
});

View File

@ -0,0 +1,159 @@
# v2-timeline-scheduler
간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
## 개요
`v2-timeline-scheduler`는 생산계획, 일정관리 등에서 사용할 수 있는 타임라인 기반의 스케줄러 컴포넌트입니다. 리소스(설비, 작업자 등)별로 스케줄을 시각화하고, 드래그/리사이즈로 일정을 조정할 수 있습니다.
## 핵심 기능
| 기능 | 설명 |
|------|------|
| 타임라인 그리드 | 일/주/월 단위 그리드 표시 |
| 스케줄 바 | 시작~종료 기간 바 렌더링 |
| 리소스 행 | 설비/작업자별 행 구분 |
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 |
| 리사이즈 | 바 양쪽 핸들로 기간 조정 |
| 줌 레벨 | 일/주/월 단위 전환 |
| 진행률 표시 | 바 내부 진행률 표시 |
| 오늘 표시선 | 현재 날짜 표시선 |
## 사용법
### 기본 사용
```tsx
import { TimelineSchedulerComponent } from "@/lib/registry/components/v2-timeline-scheduler";
<TimelineSchedulerComponent
config={{
selectedTable: "production_schedule",
resourceTable: "equipment",
fieldMapping: {
id: "id",
resourceId: "equipment_id",
title: "plan_name",
startDate: "start_date",
endDate: "end_date",
status: "status",
progress: "progress",
},
resourceFieldMapping: {
id: "id",
name: "equipment_name",
},
defaultZoomLevel: "day",
editable: true,
}}
onScheduleClick={(event) => {
console.log("클릭된 스케줄:", event.schedule);
}}
onDragEnd={(event) => {
console.log("드래그 완료:", event);
}}
/>
```
### 설정 옵션
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `selectedTable` | string | - | 스케줄 데이터 테이블명 |
| `resourceTable` | string | - | 리소스 테이블명 |
| `fieldMapping` | object | - | 스케줄 필드 매핑 |
| `resourceFieldMapping` | object | - | 리소스 필드 매핑 |
| `defaultZoomLevel` | "day" \| "week" \| "month" | "day" | 기본 줌 레벨 |
| `editable` | boolean | true | 편집 가능 여부 |
| `draggable` | boolean | true | 드래그 이동 가능 |
| `resizable` | boolean | true | 리사이즈 가능 |
| `rowHeight` | number | 50 | 행 높이 (px) |
| `headerHeight` | number | 60 | 헤더 높이 (px) |
| `resourceColumnWidth` | number | 150 | 리소스 컬럼 너비 (px) |
| `showTodayLine` | boolean | true | 오늘 표시선 |
| `showProgress` | boolean | true | 진행률 표시 |
| `showToolbar` | boolean | true | 툴바 표시 |
| `height` | number \| string | 500 | 컴포넌트 높이 |
### 필드 매핑
스케줄 테이블의 컬럼을 매핑합니다:
```typescript
fieldMapping: {
id: "id", // 필수: 고유 ID
resourceId: "equipment_id", // 필수: 리소스 ID (FK)
title: "plan_name", // 필수: 표시 제목
startDate: "start_date", // 필수: 시작일
endDate: "end_date", // 필수: 종료일
status: "status", // 선택: 상태
progress: "progress", // 선택: 진행률 (0-100)
color: "color", // 선택: 바 색상
}
```
### 이벤트
| 이벤트 | 파라미터 | 설명 |
|--------|----------|------|
| `onScheduleClick` | `{ schedule, resource }` | 스케줄 클릭 |
| `onCellClick` | `{ resourceId, date }` | 빈 셀 클릭 |
| `onDragEnd` | `{ scheduleId, newStartDate, newEndDate }` | 드래그 완료 |
| `onResizeEnd` | `{ scheduleId, newStartDate, newEndDate, direction }` | 리사이즈 완료 |
| `onAddSchedule` | `(resourceId, date)` | 추가 버튼 클릭 |
### 상태별 색상
기본 상태별 색상:
| 상태 | 색상 | 의미 |
|------|------|------|
| `planned` | 파랑 (#3b82f6) | 계획됨 |
| `in_progress` | 주황 (#f59e0b) | 진행중 |
| `completed` | 초록 (#10b981) | 완료 |
| `delayed` | 빨강 (#ef4444) | 지연 |
| `cancelled` | 회색 (#6b7280) | 취소 |
## 파일 구조
```
v2-timeline-scheduler/
├── index.ts # Definition
├── types.ts # 타입 정의
├── config.ts # 기본 설정값
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
├── README.md # 문서
├── components/
│ ├── index.ts
│ ├── TimelineHeader.tsx # 날짜 헤더
│ ├── ScheduleBar.tsx # 스케줄 바
│ └── ResourceRow.tsx # 리소스 행
└── hooks/
└── useTimelineData.ts # 데이터 관리 훅
```
## v2-table-list와의 차이점
| 특성 | v2-table-list | v2-timeline-scheduler |
|------|---------------|----------------------|
| 표현 방식 | 행 기반 테이블 | 시간축 기반 간트차트 |
| 데이터 구조 | 단순 목록 | 리소스 + 스케줄 (2개 테이블) |
| 편집 방식 | 폼 입력 | 드래그/리사이즈 |
| 시간 표현 | 텍스트 | 시각적 바 |
| 용도 | 일반 데이터 | 일정/계획 관리 |
## 향후 개선 사항
- [ ] 충돌 감지 및 표시
- [ ] 가상 스크롤 (대량 데이터)
- [ ] 마일스톤 표시
- [ ] 의존성 연결선
- [ ] 드래그로 새 스케줄 생성
- [ ] 컨텍스트 메뉴
---
**버전**: 2.0.0
**최종 수정**: 2026-01-30

View File

@ -0,0 +1,413 @@
"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Calendar,
Plus,
Loader2,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
DragEvent,
ResizeEvent,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow } from "./components";
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
/**
* v2-timeline-scheduler
*
* /
*/
export function TimelineSchedulerComponent({
config,
isDesignMode = false,
formData,
externalSchedules,
externalResources,
isLoading: externalLoading,
error: externalError,
componentId,
onDragEnd,
onResizeEnd,
onScheduleClick,
onCellClick,
onAddSchedule,
}: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
// 드래그/리사이즈 상태
const [dragState, setDragState] = useState<{
schedule: ScheduleItem;
startX: number;
startY: number;
} | null>(null);
const [resizeState, setResizeState] = useState<{
schedule: ScheduleItem;
direction: "start" | "end";
startX: number;
} | null>(null);
// 타임라인 데이터 훅
const {
schedules,
resources,
isLoading: hookLoading,
error: hookError,
zoomLevel,
setZoomLevel,
viewStartDate,
viewEndDate,
goToPrevious,
goToNext,
goToToday,
updateSchedule,
} = useTimelineData(config, externalSchedules, externalResources);
const isLoading = externalLoading ?? hookLoading;
const error = externalError ?? hookError;
// 설정값
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
const resourceColumnWidth =
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!;
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
const cellWidth = cellWidthConfig[zoomLevel] || 60;
// 리소스별 스케줄 그룹화
const schedulesByResource = useMemo(() => {
const grouped = new Map<string, ScheduleItem[]>();
resources.forEach((resource) => {
grouped.set(resource.id, []);
});
schedules.forEach((schedule) => {
const list = grouped.get(schedule.resourceId);
if (list) {
list.push(schedule);
} else {
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
const firstResource = resources[0];
if (firstResource) {
const firstList = grouped.get(firstResource.id);
if (firstList) {
firstList.push(schedule);
}
}
}
});
return grouped;
}, [schedules, resources]);
// 줌 레벨 변경
const handleZoomIn = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel);
if (currentIdx < levels.length - 1) {
setZoomLevel(levels[currentIdx + 1]);
}
}, [zoomLevel, setZoomLevel]);
const handleZoomOut = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel);
if (currentIdx > 0) {
setZoomLevel(levels[currentIdx - 1]);
}
}, [zoomLevel, setZoomLevel]);
// 스케줄 클릭 핸들러
const handleScheduleClick = useCallback(
(schedule: ScheduleItem) => {
const resource = resources.find((r) => r.id === schedule.resourceId);
if (resource && onScheduleClick) {
onScheduleClick({ schedule, resource });
}
},
[resources, onScheduleClick]
);
// 빈 셀 클릭 핸들러
const handleCellClick = useCallback(
(resourceId: string, date: Date) => {
if (onCellClick) {
onCellClick({
resourceId,
date: date.toISOString().split("T")[0],
});
}
},
[onCellClick]
);
// 드래그 시작
const handleDragStart = useCallback(
(schedule: ScheduleItem, e: React.MouseEvent) => {
setDragState({
schedule,
startX: e.clientX,
startY: e.clientY,
});
},
[]
);
// 드래그 종료
const handleDragEnd = useCallback(() => {
if (dragState) {
// TODO: 드래그 결과 계산 및 업데이트
setDragState(null);
}
}, [dragState]);
// 리사이즈 시작
const handleResizeStart = useCallback(
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
setResizeState({
schedule,
direction,
startX: e.clientX,
});
},
[]
);
// 리사이즈 종료
const handleResizeEnd = useCallback(() => {
if (resizeState) {
// TODO: 리사이즈 결과 계산 및 업데이트
setResizeState(null);
}
}, [resizeState]);
// 추가 버튼 클릭
const handleAddClick = useCallback(() => {
if (onAddSchedule && resources.length > 0) {
onAddSchedule(
resources[0].id,
new Date().toISOString().split("T")[0]
);
}
}, [onAddSchedule, resources]);
// 디자인 모드 플레이스홀더
if (isDesignMode) {
return (
<div className="w-full h-full min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg flex items-center justify-center bg-muted/10">
<div className="text-center text-muted-foreground">
<Calendar className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1">
{config.selectedTable
? `테이블: ${config.selectedTable}`
: "테이블을 선택하세요"}
</p>
</div>
</div>
);
}
// 로딩 상태
if (isLoading) {
return (
<div
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
style={{ height: config.height || 500 }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm"> ...</span>
</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div
className="w-full flex items-center justify-center bg-destructive/10 rounded-lg"
style={{ height: config.height || 500 }}
>
<div className="text-center text-destructive">
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1">{error}</p>
</div>
</div>
);
}
// 리소스 없음
if (resources.length === 0) {
return (
<div
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
style={{ height: config.height || 500 }}
>
<div className="text-center text-muted-foreground">
<Calendar className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm font-medium"> </p>
<p className="text-xs mt-1"> </p>
</div>
</div>
);
}
return (
<div
ref={containerRef}
className="w-full border rounded-lg overflow-hidden bg-background"
style={{
height: config.height || 500,
maxHeight: config.maxHeight,
}}
>
{/* 툴바 */}
{config.showToolbar !== false && (
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
{/* 네비게이션 */}
<div className="flex items-center gap-1">
{config.showNavigation !== false && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="h-7 px-2"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToToday}
className="h-7 px-2"
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="h-7 px-2"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
{/* 현재 날짜 범위 표시 */}
<span className="ml-2 text-sm text-muted-foreground">
{viewStartDate.getFullYear()} {viewStartDate.getMonth() + 1}{" "}
{viewStartDate.getDate()} ~{" "}
{viewEndDate.getMonth() + 1} {viewEndDate.getDate()}
</span>
</div>
{/* 오른쪽 컨트롤 */}
<div className="flex items-center gap-2">
{/* 줌 컨트롤 */}
{config.showZoomControls !== false && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleZoomOut}
disabled={zoomLevel === "month"}
className="h-7 px-2"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground min-w-[24px] text-center">
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleZoomIn}
disabled={zoomLevel === "day"}
className="h-7 px-2"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
)}
{/* 추가 버튼 */}
{config.showAddButton !== false && config.editable && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="h-7"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</div>
)}
{/* 타임라인 본문 */}
<div
className="overflow-auto"
style={{
height: config.showToolbar !== false
? `calc(100% - 48px)`
: "100%",
}}
>
<div className="min-w-max">
{/* 헤더 */}
<TimelineHeader
startDate={viewStartDate}
endDate={viewEndDate}
zoomLevel={zoomLevel}
cellWidth={cellWidth}
headerHeight={headerHeight}
resourceColumnWidth={resourceColumnWidth}
showTodayLine={config.showTodayLine}
/>
{/* 리소스 행들 */}
<div>
{resources.map((resource) => (
<ResourceRow
key={resource.id}
resource={resource}
schedules={schedulesByResource.get(resource.id) || []}
startDate={viewStartDate}
endDate={viewEndDate}
zoomLevel={zoomLevel}
rowHeight={rowHeight}
cellWidth={cellWidth}
resourceColumnWidth={resourceColumnWidth}
config={config}
onScheduleClick={handleScheduleClick}
onCellClick={handleCellClick}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,629 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { TimelineSchedulerConfig } from "./types";
import { zoomLevelOptions, statusOptions } from "./config";
interface TimelineSchedulerConfigPanelProps {
config: TimelineSchedulerConfig;
onChange: (config: Partial<TimelineSchedulerConfig>) => void;
}
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
columnName: string;
displayName: string;
}
export function TimelineSchedulerConfigPanel({
config,
onChange,
}: TimelineSchedulerConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
const [loading, setLoading] = useState(false);
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoading(true);
try {
const tableList = await tableTypeApi.getTables();
if (Array.isArray(tableList)) {
setTables(
tableList.map((t: any) => ({
tableName: t.table_name || t.tableName,
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
}))
);
}
} catch (err) {
console.error("테이블 목록 로드 오류:", err);
} finally {
setLoading(false);
}
};
loadTables();
}, []);
// 스케줄 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.selectedTable) {
setTableColumns([]);
return;
}
try {
const columns = await tableTypeApi.getColumns(config.selectedTable);
if (Array.isArray(columns)) {
setTableColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
);
}
} catch (err) {
console.error("컬럼 로드 오류:", err);
setTableColumns([]);
}
};
loadColumns();
}, [config.selectedTable]);
// 리소스 테이블 컬럼 로드
useEffect(() => {
const loadResourceColumns = async () => {
if (!config.resourceTable) {
setResourceColumns([]);
return;
}
try {
const columns = await tableTypeApi.getColumns(config.resourceTable);
if (Array.isArray(columns)) {
setResourceColumns(
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
);
}
} catch (err) {
console.error("리소스 컬럼 로드 오류:", err);
setResourceColumns([]);
}
};
loadResourceColumns();
}, [config.resourceTable]);
// 설정 업데이트 헬퍼
const updateConfig = (updates: Partial<TimelineSchedulerConfig>) => {
onChange({ ...config, ...updates });
};
// 필드 매핑 업데이트
const updateFieldMapping = (field: string, value: string) => {
updateConfig({
fieldMapping: {
...config.fieldMapping,
[field]: value,
},
});
};
// 리소스 필드 매핑 업데이트
const updateResourceFieldMapping = (field: string, value: string) => {
updateConfig({
resourceFieldMapping: {
...config.resourceFieldMapping,
id: config.resourceFieldMapping?.id || "id",
name: config.resourceFieldMapping?.name || "name",
[field]: value,
},
});
};
return (
<div className="space-y-4 p-4">
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
{/* 테이블 설정 */}
<AccordionItem value="table">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 스케줄 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
</span>
) : config.selectedTable ? (
tables.find((t) => t.tableName === config.selectedTable)
?.displayName || config.selectedTable
) : (
"테이블 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
if (value.toLowerCase().includes(lowerSearch)) {
return 1;
}
return 0;
}}
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({ selectedTable: table.tableName });
setTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.selectedTable === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 리소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> (/)</Label>
<Popover
open={resourceTableSelectOpen}
onOpenChange={setResourceTableSelectOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={resourceTableSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.resourceTable ? (
tables.find((t) => t.tableName === config.resourceTable)
?.displayName || config.resourceTable
) : (
"리소스 테이블 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
if (value.toLowerCase().includes(lowerSearch)) {
return 1;
}
return 0;
}}
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({ resourceTable: table.tableName });
setResourceTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.resourceTable === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</AccordionContent>
</AccordionItem>
{/* 필드 매핑 */}
<AccordionItem value="mapping">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 스케줄 필드 매핑 */}
{config.selectedTable && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* ID 필드 */}
<div className="space-y-1">
<Label className="text-[10px]">ID</Label>
<Select
value={config.fieldMapping?.id || ""}
onValueChange={(v) => updateFieldMapping("id", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 리소스 ID 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<Select
value={config.fieldMapping?.resourceId || ""}
onValueChange={(v) => updateFieldMapping("resourceId", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 제목 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={config.fieldMapping?.title || ""}
onValueChange={(v) => updateFieldMapping("title", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 시작일 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={config.fieldMapping?.startDate || ""}
onValueChange={(v) => updateFieldMapping("startDate", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 종료일 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={config.fieldMapping?.endDate || ""}
onValueChange={(v) => updateFieldMapping("endDate", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> ()</Label>
<Select
value={config.fieldMapping?.status || "__none__"}
onValueChange={(v) => updateFieldMapping("status", v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{/* 리소스 필드 매핑 */}
{config.resourceTable && (
<div className="space-y-2 mt-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* ID 필드 */}
<div className="space-y-1">
<Label className="text-[10px]">ID</Label>
<Select
value={config.resourceFieldMapping?.id || ""}
onValueChange={(v) => updateResourceFieldMapping("id", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{resourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 이름 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={config.resourceFieldMapping?.name || ""}
onValueChange={(v) => updateResourceFieldMapping("name", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{resourceColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
{/* 표시 설정 */}
<AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 기본 줌 레벨 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.defaultZoomLevel || "day"}
onValueChange={(v) =>
updateConfig({ defaultZoomLevel: v as any })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{zoomLevelOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 높이 */}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={config.height || 500}
onChange={(e) =>
updateConfig({ height: parseInt(e.target.value) || 500 })
}
className="h-8 text-xs"
/>
</div>
{/* 행 높이 */}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={config.rowHeight || 50}
onChange={(e) =>
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
}
className="h-8 text-xs"
/>
</div>
{/* 토글 스위치들 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.editable ?? true}
onCheckedChange={(v) => updateConfig({ editable: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.draggable ?? true}
onCheckedChange={(v) => updateConfig({ draggable: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(v) => updateConfig({ resizable: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showTodayLine ?? true}
onCheckedChange={(v) => updateConfig({ showTodayLine: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showProgress ?? true}
onCheckedChange={(v) => updateConfig({ showProgress: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showToolbar ?? true}
onCheckedChange={(v) => updateConfig({ showToolbar: v })}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
export default TimelineSchedulerConfigPanel;

View File

@ -0,0 +1,57 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2TimelineSchedulerDefinition } from "./index";
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
/**
* TimelineScheduler
*
*/
export class TimelineSchedulerRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2TimelineSchedulerDefinition;
render(): React.ReactElement {
return (
<TimelineSchedulerComponent
{...this.props}
config={this.props.component?.componentConfig || {}}
isDesignMode={this.props.isDesignMode}
formData={this.props.formData}
componentId={this.props.component?.id}
/>
);
}
// 설정 변경 핸들러
protected handleConfigChange = (config: any) => {
console.log("📥 TimelineSchedulerRenderer에서 설정 변경 받음:", config);
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
if (this.props.onConfigChange) {
this.props.onConfigChange(config);
}
this.updateComponent({ config });
};
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
TimelineSchedulerRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
TimelineSchedulerRenderer.registerSelf();
} catch (error) {
console.error("❌ TimelineScheduler 강제 등록 실패:", error);
}
}, 1000);
}

View File

@ -0,0 +1,206 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
import { ScheduleBar } from "./ScheduleBar";
interface ResourceRowProps {
/** 리소스 */
resource: Resource;
/** 해당 리소스의 스케줄 목록 */
schedules: ScheduleItem[];
/** 시작 날짜 */
startDate: Date;
/** 종료 날짜 */
endDate: Date;
/** 줌 레벨 */
zoomLevel: ZoomLevel;
/** 행 높이 */
rowHeight: number;
/** 셀 너비 */
cellWidth: number;
/** 리소스 컬럼 너비 */
resourceColumnWidth: number;
/** 설정 */
config: TimelineSchedulerConfig;
/** 스케줄 클릭 */
onScheduleClick?: (schedule: ScheduleItem) => void;
/** 빈 셀 클릭 */
onCellClick?: (resourceId: string, date: Date) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
}
/**
* ()
*/
const getDaysDiff = (start: Date, end: Date): number => {
const startTime = new Date(start).setHours(0, 0, 0, 0);
const endTime = new Date(end).setHours(0, 0, 0, 0);
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
};
/**
*
*/
const getCellCount = (startDate: Date, endDate: Date): number => {
return getDaysDiff(startDate, endDate) + 1;
};
export function ResourceRow({
resource,
schedules,
startDate,
endDate,
zoomLevel,
rowHeight,
cellWidth,
resourceColumnWidth,
config,
onScheduleClick,
onCellClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
}: ResourceRowProps) {
// 총 셀 개수
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
// 총 그리드 너비
const gridWidth = totalCells * cellWidth;
// 오늘 날짜
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// 스케줄 바 위치 계산
const schedulePositions = useMemo(() => {
return schedules.map((schedule) => {
const scheduleStart = new Date(schedule.startDate);
const scheduleEnd = new Date(schedule.endDate);
scheduleStart.setHours(0, 0, 0, 0);
scheduleEnd.setHours(0, 0, 0, 0);
// 시작 위치 계산
const startOffset = getDaysDiff(startDate, scheduleStart);
const left = Math.max(0, startOffset * cellWidth);
// 너비 계산
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
const visibleStartOffset = Math.max(0, startOffset);
const visibleEndOffset = Math.min(
totalCells,
startOffset + durationDays
);
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
return {
schedule,
position: {
left: resourceColumnWidth + left,
top: 0,
width,
height: rowHeight,
},
};
});
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
// 그리드 셀 클릭 핸들러
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onCellClick) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const cellIndex = Math.floor(x / cellWidth);
const clickedDate = new Date(startDate);
clickedDate.setDate(clickedDate.getDate() + cellIndex);
onCellClick(resource.id, clickedDate);
};
return (
<div
className="flex border-b hover:bg-muted/20"
style={{ height: rowHeight }}
>
{/* 리소스 컬럼 */}
<div
className="flex-shrink-0 border-r bg-muted/30 flex items-center px-3 sticky left-0 z-10"
style={{ width: resourceColumnWidth }}
>
<div className="truncate">
<div className="font-medium text-sm truncate">{resource.name}</div>
{resource.group && (
<div className="text-xs text-muted-foreground truncate">
{resource.group}
</div>
)}
</div>
</div>
{/* 타임라인 그리드 */}
<div
className="relative flex-1"
style={{ width: gridWidth }}
onClick={handleGridClick}
>
{/* 배경 그리드 */}
<div className="absolute inset-0 flex">
{Array.from({ length: totalCells }).map((_, idx) => {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + idx);
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
const isToday = cellDate.getTime() === today.getTime();
const isMonthStart = cellDate.getDate() === 1;
return (
<div
key={idx}
className={cn(
"border-r h-full",
isWeekend && "bg-muted/20",
isToday && "bg-primary/5",
isMonthStart && "border-l-2 border-l-primary/20"
)}
style={{ width: cellWidth }}
/>
);
})}
</div>
{/* 스케줄 바들 */}
{schedulePositions.map(({ schedule, position }) => (
<ScheduleBar
key={schedule.id}
schedule={schedule}
position={{
...position,
left: position.left - resourceColumnWidth, // 상대 위치
}}
config={config}
draggable={config.draggable}
resizable={config.resizable}
onClick={() => onScheduleClick?.(schedule)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
"use client";
import React, { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
import { statusOptions } from "../config";
interface ScheduleBarProps {
/** 스케줄 항목 */
schedule: ScheduleItem;
/** 위치 정보 */
position: ScheduleBarPosition;
/** 설정 */
config: TimelineSchedulerConfig;
/** 드래그 가능 여부 */
draggable?: boolean;
/** 리사이즈 가능 여부 */
resizable?: boolean;
/** 클릭 이벤트 */
onClick?: (schedule: ScheduleItem) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 중 */
onDrag?: (deltaX: number, deltaY: number) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 중 */
onResize?: (deltaX: number, direction: "start" | "end") => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
}
export function ScheduleBar({
schedule,
position,
config,
draggable = true,
resizable = true,
onClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
}: ScheduleBarProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
// 상태에 따른 색상
const statusColor = schedule.color ||
config.statusColors?.[schedule.status] ||
statusOptions.find((s) => s.value === schedule.status)?.color ||
"#3b82f6";
// 진행률 바 너비
const progressWidth = config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%`
: "0%";
// 드래그 시작 핸들러
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!draggable || isResizing) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
onDragStart?.(schedule, e);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 드래그 중 로직은 부모에서 처리
};
const handleMouseUp = () => {
setIsDragging(false);
onDragEnd?.();
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[draggable, isResizing, schedule, onDragStart, onDragEnd]
);
// 리사이즈 시작 핸들러
const handleResizeStart = useCallback(
(direction: "start" | "end", e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
onResizeStart?.(schedule, direction, e);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 리사이즈 중 로직은 부모에서 처리
};
const handleMouseUp = () => {
setIsResizing(false);
onResizeEnd?.();
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[resizable, schedule, onResizeStart, onResizeEnd]
);
// 클릭 핸들러
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isDragging || isResizing) return;
e.stopPropagation();
onClick?.(schedule);
},
[isDragging, isResizing, onClick, schedule]
);
return (
<div
ref={barRef}
className={cn(
"absolute rounded-md shadow-sm cursor-pointer transition-shadow",
"hover:shadow-md hover:z-10",
isDragging && "opacity-70 shadow-lg z-20",
isResizing && "z-20",
draggable && "cursor-grab",
isDragging && "cursor-grabbing"
)}
style={{
left: position.left,
top: position.top + 4,
width: position.width,
height: position.height - 8,
backgroundColor: statusColor,
}}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{/* 진행률 바 */}
{config.showProgress && schedule.progress !== undefined && (
<div
className="absolute inset-y-0 left-0 rounded-l-md opacity-30 bg-white"
style={{ width: progressWidth }}
/>
)}
{/* 제목 */}
<div className="relative z-10 px-2 py-1 text-xs text-white truncate font-medium">
{schedule.title}
</div>
{/* 진행률 텍스트 */}
{config.showProgress && schedule.progress !== undefined && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-white/80 font-medium">
{schedule.progress}%
</div>
)}
{/* 리사이즈 핸들 - 왼쪽 */}
{resizable && (
<div
className="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-l-md"
onMouseDown={(e) => handleResizeStart("start", e)}
/>
)}
{/* 리사이즈 핸들 - 오른쪽 */}
{resizable && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-r-md"
onMouseDown={(e) => handleResizeStart("end", e)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,195 @@
"use client";
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { DateCell, ZoomLevel } from "../types";
import { dayLabels, monthLabels } from "../config";
interface TimelineHeaderProps {
/** 시작 날짜 */
startDate: Date;
/** 종료 날짜 */
endDate: Date;
/** 줌 레벨 */
zoomLevel: ZoomLevel;
/** 셀 너비 */
cellWidth: number;
/** 헤더 높이 */
headerHeight: number;
/** 리소스 컬럼 너비 */
resourceColumnWidth: number;
/** 오늘 표시선 */
showTodayLine?: boolean;
}
/**
*
*/
const generateDateCells = (
startDate: Date,
endDate: Date,
zoomLevel: ZoomLevel
): DateCell[] => {
const cells: DateCell[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
while (current <= endDate) {
const date = new Date(current);
const dayOfWeek = date.getDay();
const isToday = date.getTime() === today.getTime();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isMonthStart = date.getDate() === 1;
let label = "";
if (zoomLevel === "day") {
label = `${date.getDate()}(${dayLabels[dayOfWeek]})`;
} else if (zoomLevel === "week") {
// 주간: 월요일 기준 주 시작
if (dayOfWeek === 1 || cells.length === 0) {
label = `${date.getMonth() + 1}/${date.getDate()}`;
}
} else if (zoomLevel === "month") {
// 월간: 월 시작일만 표시
if (isMonthStart || cells.length === 0) {
label = monthLabels[date.getMonth()];
}
}
cells.push({
date,
label,
isToday,
isWeekend,
isMonthStart,
});
current.setDate(current.getDate() + 1);
}
return cells;
};
/**
* ( )
*/
const generateMonthGroups = (
cells: DateCell[]
): { month: string; year: number; count: number }[] => {
const groups: { month: string; year: number; count: number }[] = [];
cells.forEach((cell) => {
const month = monthLabels[cell.date.getMonth()];
const year = cell.date.getFullYear();
if (
groups.length === 0 ||
groups[groups.length - 1].month !== month ||
groups[groups.length - 1].year !== year
) {
groups.push({ month, year, count: 1 });
} else {
groups[groups.length - 1].count++;
}
});
return groups;
};
export function TimelineHeader({
startDate,
endDate,
zoomLevel,
cellWidth,
headerHeight,
resourceColumnWidth,
showTodayLine = true,
}: TimelineHeaderProps) {
// 날짜 셀 생성
const dateCells = useMemo(
() => generateDateCells(startDate, endDate, zoomLevel),
[startDate, endDate, zoomLevel]
);
// 월 그룹 생성
const monthGroups = useMemo(() => generateMonthGroups(dateCells), [dateCells]);
// 오늘 위치 계산
const todayPosition = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayIndex = dateCells.findIndex(
(cell) => cell.date.getTime() === today.getTime()
);
if (todayIndex === -1) return null;
return resourceColumnWidth + todayIndex * cellWidth + cellWidth / 2;
}, [dateCells, cellWidth, resourceColumnWidth]);
return (
<div
className="sticky top-0 z-20 border-b bg-background"
style={{ height: headerHeight }}
>
{/* 상단 행: 월/년도 */}
<div className="flex" style={{ height: headerHeight / 2 }}>
{/* 리소스 컬럼 헤더 */}
<div
className="flex-shrink-0 border-r bg-muted/50 flex items-center justify-center font-medium text-sm"
style={{ width: resourceColumnWidth }}
>
</div>
{/* 월 그룹 */}
{monthGroups.map((group, idx) => (
<div
key={`${group.year}-${group.month}-${idx}`}
className="border-r flex items-center justify-center text-xs font-medium text-muted-foreground"
style={{ width: group.count * cellWidth }}
>
{group.year} {group.month}
</div>
))}
</div>
{/* 하단 행: 일자 */}
<div className="flex" style={{ height: headerHeight / 2 }}>
{/* 리소스 컬럼 (빈칸) */}
<div
className="flex-shrink-0 border-r bg-muted/50"
style={{ width: resourceColumnWidth }}
/>
{/* 날짜 셀 */}
{dateCells.map((cell, idx) => (
<div
key={idx}
className={cn(
"border-r flex items-center justify-center text-xs",
cell.isToday && "bg-primary/10 font-bold text-primary",
cell.isWeekend && !cell.isToday && "bg-muted/30 text-muted-foreground",
cell.isMonthStart && "border-l-2 border-l-primary/30"
)}
style={{ width: cellWidth }}
>
{cell.label}
</div>
))}
</div>
{/* 오늘 표시선 */}
{showTodayLine && todayPosition !== null && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-primary z-30 pointer-events-none"
style={{ left: todayPosition }}
/>
)}
</div>
);
}

View File

@ -0,0 +1,3 @@
export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";

View File

@ -0,0 +1,102 @@
"use client";
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
/**
*
*/
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
defaultZoomLevel: "day",
editable: true,
draggable: true,
resizable: true,
rowHeight: 50,
headerHeight: 60,
resourceColumnWidth: 150,
cellWidth: {
day: 60,
week: 120,
month: 40,
},
showConflicts: true,
showProgress: true,
showTodayLine: true,
showToolbar: true,
showZoomControls: true,
showNavigation: true,
showAddButton: true,
height: 500,
statusColors: {
planned: "#3b82f6", // blue-500
in_progress: "#f59e0b", // amber-500
completed: "#10b981", // emerald-500
delayed: "#ef4444", // red-500
cancelled: "#6b7280", // gray-500
},
fieldMapping: {
id: "id",
resourceId: "resource_id",
title: "title",
startDate: "start_date",
endDate: "end_date",
status: "status",
progress: "progress",
},
resourceFieldMapping: {
id: "id",
name: "name",
group: "group",
},
};
/**
*
*/
export const zoomLevelOptions: { value: ZoomLevel; label: string }[] = [
{ value: "day", label: "일" },
{ value: "week", label: "주" },
{ value: "month", label: "월" },
];
/**
*
*/
export const statusOptions = [
{ value: "planned", label: "계획됨", color: "#3b82f6" },
{ value: "in_progress", label: "진행중", color: "#f59e0b" },
{ value: "completed", label: "완료", color: "#10b981" },
{ value: "delayed", label: "지연", color: "#ef4444" },
{ value: "cancelled", label: "취소", color: "#6b7280" },
];
/**
*
*/
export const zoomLevelDays: Record<ZoomLevel, number> = {
day: 14, // 2주
week: 56, // 8주
month: 90, // 3개월
};
/**
* ()
*/
export const dayLabels = ["일", "월", "화", "수", "목", "금", "토"];
/**
* ()
*/
export const monthLabels = [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];

View File

@ -0,0 +1,331 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import {
TimelineSchedulerConfig,
ScheduleItem,
Resource,
ZoomLevel,
UseTimelineDataResult,
} from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
/**
* ISO ( )
*/
const toDateString = (date: Date): string => {
return date.toISOString().split("T")[0];
};
/**
*
*/
const addDays = (date: Date, days: number): Date => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
/**
*
*/
export function useTimelineData(
config: TimelineSchedulerConfig,
externalSchedules?: ScheduleItem[],
externalResources?: Resource[]
): UseTimelineDataResult {
// 상태
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
config.defaultZoomLevel || "day"
);
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) {
return new Date(config.initialDate);
}
// 오늘 기준 1주일 전부터 시작
const today = new Date();
today.setDate(today.getDate() - 7);
today.setHours(0, 0, 0, 0);
return today;
});
// 표시 종료일 계산
const viewEndDate = useMemo(() => {
const days = zoomLevelDays[zoomLevel];
return addDays(viewStartDate, days);
}, [viewStartDate, zoomLevel]);
// 테이블명
const tableName = config.useCustomTable
? config.customTableName
: config.selectedTable;
const resourceTableName = config.resourceTable;
// 필드 매핑
const fieldMapping = config.fieldMapping || defaultTimelineSchedulerConfig.fieldMapping!;
const resourceFieldMapping =
config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
// 스케줄 데이터 로드
const fetchSchedules = useCallback(async () => {
if (externalSchedules) {
setSchedules(externalSchedules);
return;
}
if (!tableName) {
setSchedules([]);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
page: 1,
size: 10000,
autoFilter: true,
search: {
// 표시 범위 내의 스케줄만 조회
[fieldMapping.startDate]: {
value: toDateString(viewEndDate),
operator: "lte",
},
[fieldMapping.endDate]: {
value: toDateString(viewStartDate),
operator: "gte",
},
},
}
);
const responseData =
response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 ScheduleItem 형태로 변환
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({
id: String(row[fieldMapping.id] || ""),
resourceId: String(row[fieldMapping.resourceId] || ""),
title: String(row[fieldMapping.title] || ""),
startDate: row[fieldMapping.startDate] || "",
endDate: row[fieldMapping.endDate] || "",
status: fieldMapping.status
? row[fieldMapping.status] || "planned"
: "planned",
progress: fieldMapping.progress
? Number(row[fieldMapping.progress]) || 0
: undefined,
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
data: row,
}));
setSchedules(mappedSchedules);
} catch (err: any) {
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
setSchedules([]);
} finally {
setIsLoading(false);
}
}, [
tableName,
externalSchedules,
fieldMapping,
viewStartDate,
viewEndDate,
]);
// 리소스 데이터 로드
const fetchResources = useCallback(async () => {
if (externalResources) {
setResources(externalResources);
return;
}
if (!resourceTableName) {
setResources([]);
return;
}
try {
const response = await apiClient.post(
`/table-management/tables/${resourceTableName}/data`,
{
page: 1,
size: 1000,
autoFilter: true,
}
);
const responseData =
response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 Resource 형태로 변환
const mappedResources: Resource[] = rawData.map((row: any) => ({
id: String(row[resourceFieldMapping.id] || ""),
name: String(row[resourceFieldMapping.name] || ""),
group: resourceFieldMapping.group
? row[resourceFieldMapping.group]
: undefined,
}));
setResources(mappedResources);
} catch (err: any) {
console.error("리소스 로드 오류:", err);
setResources([]);
}
}, [resourceTableName, externalResources, resourceFieldMapping]);
// 초기 로드
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
useEffect(() => {
fetchResources();
}, [fetchResources]);
// 네비게이션 함수들
const goToPrevious = useCallback(() => {
const days = zoomLevelDays[zoomLevel];
setViewStartDate((prev) => addDays(prev, -days));
}, [zoomLevel]);
const goToNext = useCallback(() => {
const days = zoomLevelDays[zoomLevel];
setViewStartDate((prev) => addDays(prev, days));
}, [zoomLevel]);
const goToToday = useCallback(() => {
const today = new Date();
today.setDate(today.getDate() - 7);
today.setHours(0, 0, 0, 0);
setViewStartDate(today);
}, []);
const goToDate = useCallback((date: Date) => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() - 7);
newDate.setHours(0, 0, 0, 0);
setViewStartDate(newDate);
}, []);
// 스케줄 업데이트
const updateSchedule = useCallback(
async (id: string, updates: Partial<ScheduleItem>) => {
if (!tableName || !config.editable) return;
try {
// 필드 매핑 역변환
const updateData: Record<string, any> = {};
if (updates.startDate) updateData[fieldMapping.startDate] = updates.startDate;
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
if (updates.title) updateData[fieldMapping.title] = updates.title;
if (updates.status && fieldMapping.status)
updateData[fieldMapping.status] = updates.status;
if (updates.progress !== undefined && fieldMapping.progress)
updateData[fieldMapping.progress] = updates.progress;
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
// 로컬 상태 업데이트
setSchedules((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
} catch (err: any) {
console.error("스케줄 업데이트 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable]
);
// 스케줄 추가
const addSchedule = useCallback(
async (schedule: Omit<ScheduleItem, "id">) => {
if (!tableName || !config.editable) return;
try {
// 필드 매핑 역변환
const insertData: Record<string, any> = {
[fieldMapping.resourceId]: schedule.resourceId,
[fieldMapping.title]: schedule.title,
[fieldMapping.startDate]: schedule.startDate,
[fieldMapping.endDate]: schedule.endDate,
};
if (fieldMapping.status) insertData[fieldMapping.status] = schedule.status;
if (fieldMapping.progress && schedule.progress !== undefined)
insertData[fieldMapping.progress] = schedule.progress;
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
insertData
);
const newId = response.data?.data?.id || Date.now().toString();
// 로컬 상태 업데이트
setSchedules((prev) => [...prev, { ...schedule, id: newId }]);
} catch (err: any) {
console.error("스케줄 추가 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable]
);
// 스케줄 삭제
const deleteSchedule = useCallback(
async (id: string) => {
if (!tableName || !config.editable) return;
try {
await apiClient.delete(`/table-management/tables/${tableName}/data/${id}`);
// 로컬 상태 업데이트
setSchedules((prev) => prev.filter((s) => s.id !== id));
} catch (err: any) {
console.error("스케줄 삭제 오류:", err);
throw err;
}
},
[tableName, config.editable]
);
// 새로고침
const refresh = useCallback(() => {
fetchSchedules();
fetchResources();
}, [fetchSchedules, fetchResources]);
return {
schedules,
resources,
isLoading,
error,
zoomLevel,
setZoomLevel,
viewStartDate,
viewEndDate,
goToPrevious,
goToNext,
goToToday,
goToDate,
updateSchedule,
addSchedule,
deleteSchedule,
refresh,
};
}

View File

@ -0,0 +1,38 @@
"use client";
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
import { defaultTimelineSchedulerConfig } from "./config";
import { TimelineSchedulerConfig } from "./types";
/**
* v2-timeline-scheduler
* /
*/
export const V2TimelineSchedulerDefinition = createComponentDefinition({
id: "v2-timeline-scheduler",
name: "타임라인 스케줄러",
nameEng: "Timeline Scheduler Component",
description: "간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: TimelineSchedulerComponent,
configPanel: TimelineSchedulerConfigPanel,
defaultConfig: defaultTimelineSchedulerConfig as TimelineSchedulerConfig,
defaultSize: {
width: 1000,
height: 500,
},
icon: "Calendar",
tags: ["타임라인", "스케줄", "간트차트", "일정", "계획"],
version: "2.0.0",
author: "개발팀",
documentation: "",
});
export { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
export { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
export * from "./types";
export * from "./config";

View File

@ -0,0 +1,363 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* ( )
*/
export type ZoomLevel = "day" | "week" | "month";
/**
*
*/
export type ScheduleStatus =
| "planned"
| "in_progress"
| "completed"
| "delayed"
| "cancelled";
/**
* ( )
*/
export interface ScheduleItem {
/** 고유 ID */
id: string;
/** 리소스 ID (설비/작업자) */
resourceId: string;
/** 표시 제목 */
title: string;
/** 시작 일시 (ISO 8601) */
startDate: string;
/** 종료 일시 (ISO 8601) */
endDate: string;
/** 상태 */
status: ScheduleStatus;
/** 진행률 (0-100) */
progress?: number;
/** 색상 (CSS color) */
color?: string;
/** 추가 데이터 */
data?: Record<string, any>;
}
/**
* ( - /)
*/
export interface Resource {
/** 리소스 ID */
id: string;
/** 표시명 */
name: string;
/** 그룹 (선택) */
group?: string;
/** 아이콘 (선택) */
icon?: string;
/** 용량 (선택, 충돌 계산용) */
capacity?: number;
}
/**
*
*/
export interface FieldMapping {
/** ID 필드 */
id: string;
/** 리소스 ID 필드 */
resourceId: string;
/** 제목 필드 */
title: string;
/** 시작일 필드 */
startDate: string;
/** 종료일 필드 */
endDate: string;
/** 상태 필드 (선택) */
status?: string;
/** 진행률 필드 (선택) */
progress?: string;
/** 색상 필드 (선택) */
color?: string;
}
/**
*
*/
export interface ResourceFieldMapping {
/** ID 필드 */
id: string;
/** 이름 필드 */
name: string;
/** 그룹 필드 (선택) */
group?: string;
}
/**
*
*/
export interface TimelineSchedulerConfig extends ComponentConfig {
/** 스케줄 데이터 테이블명 */
selectedTable?: string;
/** 리소스 테이블명 */
resourceTable?: string;
/** 스케줄 필드 매핑 */
fieldMapping: FieldMapping;
/** 리소스 필드 매핑 */
resourceFieldMapping?: ResourceFieldMapping;
/** 초기 줌 레벨 */
defaultZoomLevel?: ZoomLevel;
/** 초기 표시 날짜 (ISO 8601) */
initialDate?: string;
/** 편집 가능 여부 */
editable?: boolean;
/** 드래그 이동 가능 */
draggable?: boolean;
/** 리사이즈 가능 */
resizable?: boolean;
/** 행 높이 (px) */
rowHeight?: number;
/** 헤더 높이 (px) */
headerHeight?: number;
/** 리소스 컬럼 너비 (px) */
resourceColumnWidth?: number;
/** 셀 너비 (px, 줌 레벨별) */
cellWidth?: {
day?: number;
week?: number;
month?: number;
};
/** 충돌 표시 여부 */
showConflicts?: boolean;
/** 진행률 바 표시 여부 */
showProgress?: boolean;
/** 오늘 표시선 */
showTodayLine?: boolean;
/** 상태별 색상 */
statusColors?: {
planned?: string;
in_progress?: string;
completed?: string;
delayed?: string;
cancelled?: string;
};
/** 툴바 표시 여부 */
showToolbar?: boolean;
/** 줌 레벨 변경 버튼 표시 */
showZoomControls?: boolean;
/** 네비게이션 버튼 표시 */
showNavigation?: boolean;
/** 추가 버튼 표시 */
showAddButton?: boolean;
/** 높이 (px 또는 auto) */
height?: number | string;
/** 최대 높이 */
maxHeight?: number | string;
}
/**
*
*/
export interface DragEvent {
/** 스케줄 ID */
scheduleId: string;
/** 새로운 시작일 */
newStartDate: string;
/** 새로운 종료일 */
newEndDate: string;
/** 새로운 리소스 ID (리소스 간 이동 시) */
newResourceId?: string;
}
/**
*
*/
export interface ResizeEvent {
/** 스케줄 ID */
scheduleId: string;
/** 새로운 시작일 */
newStartDate: string;
/** 새로운 종료일 */
newEndDate: string;
/** 리사이즈 방향 */
direction: "start" | "end";
}
/**
*
*/
export interface ScheduleClickEvent {
/** 스케줄 항목 */
schedule: ScheduleItem;
/** 리소스 */
resource: Resource;
}
/**
*
*/
export interface CellClickEvent {
/** 리소스 ID */
resourceId: string;
/** 날짜 */
date: string;
}
/**
* TimelineSchedulerComponent Props
*/
export interface TimelineSchedulerComponentProps {
/** 컴포넌트 설정 */
config: TimelineSchedulerConfig;
/** 디자인 모드 여부 */
isDesignMode?: boolean;
/** 폼 데이터 */
formData?: Record<string, any>;
/** 외부 스케줄 데이터 */
externalSchedules?: ScheduleItem[];
/** 외부 리소스 데이터 */
externalResources?: Resource[];
/** 로딩 상태 */
isLoading?: boolean;
/** 에러 */
error?: string;
/** 컴포넌트 ID */
componentId?: string;
/** 드래그 완료 이벤트 */
onDragEnd?: (event: DragEvent) => void;
/** 리사이즈 완료 이벤트 */
onResizeEnd?: (event: ResizeEvent) => void;
/** 스케줄 클릭 이벤트 */
onScheduleClick?: (event: ScheduleClickEvent) => void;
/** 빈 셀 클릭 이벤트 */
onCellClick?: (event: CellClickEvent) => void;
/** 스케줄 추가 이벤트 */
onAddSchedule?: (resourceId: string, date: string) => void;
}
/**
* useTimelineData
*/
export interface UseTimelineDataResult {
/** 스케줄 목록 */
schedules: ScheduleItem[];
/** 리소스 목록 */
resources: Resource[];
/** 로딩 상태 */
isLoading: boolean;
/** 에러 */
error: string | null;
/** 현재 줌 레벨 */
zoomLevel: ZoomLevel;
/** 줌 레벨 변경 */
setZoomLevel: (level: ZoomLevel) => void;
/** 현재 표시 시작일 */
viewStartDate: Date;
/** 현재 표시 종료일 */
viewEndDate: Date;
/** 이전으로 이동 */
goToPrevious: () => void;
/** 다음으로 이동 */
goToNext: () => void;
/** 오늘로 이동 */
goToToday: () => void;
/** 특정 날짜로 이동 */
goToDate: (date: Date) => void;
/** 스케줄 업데이트 */
updateSchedule: (id: string, updates: Partial<ScheduleItem>) => Promise<void>;
/** 스케줄 추가 */
addSchedule: (schedule: Omit<ScheduleItem, "id">) => Promise<void>;
/** 스케줄 삭제 */
deleteSchedule: (id: string) => Promise<void>;
/** 데이터 새로고침 */
refresh: () => void;
}
/**
*
*/
export interface DateCell {
/** 날짜 */
date: Date;
/** 표시 라벨 */
label: string;
/** 오늘 여부 */
isToday: boolean;
/** 주말 여부 */
isWeekend: boolean;
/** 월 첫째날 여부 */
isMonthStart: boolean;
}
/**
*
*/
export interface ScheduleBarPosition {
/** 왼쪽 오프셋 (px) */
left: number;
/** 너비 (px) */
width: number;
/** 상단 오프셋 (px) */
top: number;
/** 높이 (px) */
height: number;
}

View File

@ -66,6 +66,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
codeCategory: overrides.codeCategory,
inputType: overrides.inputType,
webType: overrides.webType,
// 🆕 autoFill 설정 복원 (자동 입력 기능)
autoFill: overrides.autoFill,
// 기존 구조 호환을 위한 추가 필드
style: {},
parentId: null,
@ -115,6 +117,8 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory;
if (comp.inputType) topLevelProps.inputType = comp.inputType;
if (comp.webType) topLevelProps.webType = comp.webType;
// 🆕 autoFill 설정 저장 (자동 입력 기능)
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
// 현재 설정에서 차이값만 추출
const fullConfig = comp.componentConfig || {};