Compare commits

...

21 Commits

Author SHA1 Message Date
kjs 09f477172c Merge main into feature/screen-management (menuCopyService 충돌 해결) 2025-12-19 16:07:41 +09:00
kjs 483dbf8a1f fix: scope_type=menu 채번규칙 삭제 시 check constraint 위반 해결
- scope_type='menu'인 채번규칙: 메뉴 삭제 시 함께 삭제 (파트 포함)
- scope_type!='menu'인 채번규칙: menu_objid만 NULL로 설정 (규칙 보존)
- check_menu_scope_requires_menu_objid 제약조건 준수
2025-12-19 15:52:53 +09:00
hyeonsu f1c775b691 Merge pull request 'reportMng' (#305) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/305
2025-12-19 14:12:32 +09:00
dohyeons 69754a31cb 디지털 3d 뷰어 10초단위 갱신 구현 2025-12-19 14:12:05 +09:00
dohyeons 2e7a215066 오른쪽 그리드 크기 조절 2025-12-19 14:00:38 +09:00
kjs 228c497569 fix: 메뉴 복사 로직 개선 - FK 에러 해결 및 성능 최적화
- numbering_rules FK 에러 해결 (menu_objid NULL 설정)
- category_column_mapping FK 에러 해결 (삭제 후 재복사)
- 채번규칙 매핑 보완 로직 추가 (화면에서 참조하는 채번규칙을 이름으로 찾아 매핑)
- 기존 채번규칙/카테고리 매핑의 menu_objid 갱신 로직 추가
- N+1 쿼리 최적화 (배치 조회/삽입으로 변경)
  - 메뉴 삭제: N개 쿼리 → 1개
  - 화면 할당/플로우 수집: N개 쿼리 → 1개
  - 화면 정의 조회: N개 쿼리 → 1개
  - 레이아웃 삽입: N개 쿼리 → 화면당 1개
  - 채번규칙/카테고리 매핑 업데이트: CASE WHEN 배치 처리
- 예상 성능 개선: ~10배
2025-12-19 13:50:13 +09:00
hyeonsu 01422e035b Merge pull request '위젯 컴팩트 모드 제거' (#304) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/304
2025-12-19 13:48:07 +09:00
dohyeons adb21a5308 위젯 컴팩트 모드 제거 2025-12-19 13:47:30 +09:00
hyeonsu 9902b65598 Merge pull request '외부 업체 전용 뷰어 모드 구현' (#303) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/303
2025-12-19 09:42:11 +09:00
dohyeons 981ec27ed7 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-19 09:41:29 +09:00
dohyeons 06d2cf7f72 외부 업체 전용 뷰어 모드 구현 2025-12-18 18:14:27 +09:00
hyeonsu bdb70ce5b7 Merge pull request '3d 전체화면 수정' (#302) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/302
2025-12-18 16:32:04 +09:00
dohyeons 8306d7961c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 16:31:37 +09:00
dohyeons 61ceab1a7b 외부 업체일 때만 전체화면 되도록 수정 2025-12-18 16:31:25 +09:00
hyeonsu 90d136ca85 Merge pull request '티라유텍 수정사항 적용' (#301) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/301
2025-12-18 16:04:27 +09:00
dohyeons da24db8f37 외부 업체 전용 모드 및 3D 캔버스 전체 화면 기능 구현 2025-12-18 16:03:47 +09:00
dohyeons a617c26721 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng 2025-12-18 16:02:54 +09:00
dohyeons a24654c867 디지털 트윈 자재 목록 테이블 가독성 개선 2025-12-18 15:11:03 +09:00
dohyeons 79c1a456f0 리스트 위젯 컴팩트 모드 추가 (세로 1칸 대응) 2025-12-18 15:04:55 +09:00
dohyeons ca86c0a10f 위젯 컴팩트 모드 추가 (1x1 사이즈 대응) 2025-12-18 14:42:58 +09:00
kjs 4e987f208a Merge pull request 'feature/screen-management' (#300) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/300
2025-12-18 14:34:54 +09:00
7 changed files with 991 additions and 508 deletions

View File

@ -22,6 +22,15 @@ const router = Router();
// 모든 role 라우트에 인증 미들웨어 적용 // 모든 role 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
/**
* (/:id )
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
/** /**
* CRUD * CRUD
*/ */
@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
// 메뉴 권한 설정 // 메뉴 권한 설정
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
/**
*
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
export default router; export default router;

View File

@ -299,19 +299,17 @@ export class MenuCopyService {
const screenIds = new Set<number>(); const screenIds = new Set<number>();
const visited = new Set<number>(); const visited = new Set<number>();
// 1) 메뉴에 직접 할당된 화면 // 1) 메뉴에 직접 할당된 화면 - 배치 조회
for (const menuObjid of menuObjids) {
const assignmentsResult = await client.query<{ screen_id: number }>( const assignmentsResult = await client.query<{ screen_id: number }>(
`SELECT DISTINCT screen_id `SELECT DISTINCT screen_id
FROM screen_menu_assignments FROM screen_menu_assignments
WHERE menu_objid = $1 AND company_code = $2`, WHERE menu_objid = ANY($1) AND company_code = $2`,
[menuObjid, sourceCompanyCode] [menuObjids, sourceCompanyCode]
); );
for (const assignment of assignmentsResult.rows) { for (const assignment of assignmentsResult.rows) {
screenIds.add(assignment.screen_id); screenIds.add(assignment.screen_id);
} }
}
logger.info(`📌 직접 할당 화면: ${screenIds.size}`); logger.info(`📌 직접 할당 화면: ${screenIds.size}`);
@ -371,20 +369,27 @@ export class MenuCopyService {
screenId: number; screenId: number;
}> = []; }> = [];
for (const screenId of screenIds) { // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회
const layoutsResult = await client.query<ScreenLayout>( const screenIdArray = Array.from(screenIds);
`SELECT properties FROM screen_layouts WHERE screen_id = $1`, if (screenIdArray.length === 0) {
[screenId] return flowIds;
}
const layoutsResult = await client.query<
ScreenLayout & { screen_id: number }
>(
`SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`,
[screenIdArray]
); );
for (const layout of layoutsResult.rows) { for (const layout of layoutsResult.rows) {
const props = layout.properties; const props = layout.properties;
const screenId = layout.screen_id;
// webTypeConfig.dataflowConfig.flowConfig.flowId // webTypeConfig.dataflowConfig.flowConfig.flowId
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
const flowName = const flowName =
props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown";
"Unknown";
if (flowId && typeof flowId === "number" && flowId > 0) { if (flowId && typeof flowId === "number" && flowId > 0) {
if (!flowIds.has(flowId)) { if (!flowIds.has(flowId)) {
@ -417,7 +422,6 @@ export class MenuCopyService {
} }
} }
} }
}
if (flowIds.size > 0) { if (flowIds.size > 0) {
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}`); logger.info(`✅ 플로우 수집 완료: ${flowIds.size}`);
@ -582,6 +586,12 @@ export class MenuCopyService {
logger.info( logger.info(
` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value}${newRuleId}` ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value}${newRuleId}`
); );
} else {
// 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지)
logger.warn(
` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정`
);
obj[key] = "";
} }
} }
@ -728,7 +738,7 @@ export class MenuCopyService {
]); ]);
logger.info(` ✅ 메뉴 권한 삭제 완료`); logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-4. 채번 규칙 처리 (외래키 제약조건 해결) // 5-4. 채번 규칙 처리 (체크 제약조건 고려)
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
const menuScopedRulesResult = await client.query( const menuScopedRulesResult = await client.query(
@ -746,17 +756,16 @@ export class MenuCopyService {
[menuScopedRuleIds] [menuScopedRuleIds]
); );
// 채번 규칙 삭제 // 채번 규칙 삭제
await client.query( await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, menuScopedRuleIds,
[menuScopedRuleIds] ]);
);
logger.info( logger.info(
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}` ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}`
); );
} }
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
const tableScopedRulesResult = await client.query( const updatedNumberingRules = await client.query(
`UPDATE numbering_rules `UPDATE numbering_rules
SET menu_objid = NULL SET menu_objid = NULL
WHERE menu_objid = ANY($1) AND company_code = $2 WHERE menu_objid = ANY($1) AND company_code = $2
@ -764,29 +773,36 @@ export class MenuCopyService {
RETURNING rule_id`, RETURNING rule_id`,
[existingMenuIds, targetCompanyCode] [existingMenuIds, targetCompanyCode]
); );
if (tableScopedRulesResult.rows.length > 0) { if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) {
logger.info( logger.info(
` ✅ 테이블 스코프 채번 규칙 연결 해제: ${tableScopedRulesResult.rows.length}개 (데이터 보존)` ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)`
); );
} }
// 5-5. 카테고리 컬럼 매핑 삭제 (NOT NULL 제약조건으로 삭제 필요) // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
const deletedCategoryMappings = await client.query( const deletedCategoryMappings = await client.query(
`DELETE FROM category_column_mapping `DELETE FROM category_column_mapping
WHERE menu_objid = ANY($1) AND company_code = $2 WHERE menu_objid = ANY($1) AND company_code = $2
RETURNING mapping_id`, RETURNING mapping_id`,
[existingMenuIds, targetCompanyCode] [existingMenuIds, targetCompanyCode]
); );
if (deletedCategoryMappings.rows.length > 0) { if (
deletedCategoryMappings.rowCount &&
deletedCategoryMappings.rowCount > 0
) {
logger.info( logger.info(
` ✅ 카테고리 매핑 삭제: ${deletedCategoryMappings.rows.length}` ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}`
); );
} }
// 5-6. 메뉴 삭제 (배치) // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용)
// 외래키 제약이 해제되었으므로 배치 삭제 가능
if (existingMenuIds.length > 0) {
await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [
existingMenuIds, existingMenuIds,
]); ]);
}
logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`); logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`);
logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨");
@ -939,6 +955,20 @@ export class MenuCopyService {
); );
} }
// === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 ===
// 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을
// 대상 회사에서 같은 이름의 채번규칙으로 매핑
if (screenIds.size > 0) {
logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완");
await this.supplementNumberingRuleMapping(
Array.from(screenIds),
sourceCompanyCode,
targetCompanyCode,
numberingRuleIdMap,
client
);
}
// === 5단계: 화면 복사 === // === 5단계: 화면 복사 ===
logger.info("\n📄 [5단계] 화면 복사"); logger.info("\n📄 [5단계] 화면 복사");
const screenIdMap = await this.copyScreens( const screenIdMap = await this.copyScreens(
@ -1293,6 +1323,37 @@ export class MenuCopyService {
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}`); logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}`);
// === 0단계: 원본 화면 정의 배치 조회 ===
const screenIdArray = Array.from(screenIds);
const allScreenDefsResult = await client.query<ScreenDefinition>(
`SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`,
[screenIdArray]
);
const screenDefMap = new Map<number, ScreenDefinition>();
for (const def of allScreenDefsResult.rows) {
screenDefMap.set(def.screen_id, def);
}
// 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준)
const existingCopiesResult = await client.query<{
screen_id: number;
screen_name: string;
source_screen_id: number;
updated_date: Date;
}>(
`SELECT screen_id, screen_name, source_screen_id, updated_date
FROM screen_definitions
WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`,
[screenIdArray, targetCompanyCode]
);
const existingCopyMap = new Map<
number,
{ screen_id: number; screen_name: string; updated_date: Date }
>();
for (const copy of existingCopiesResult.rows) {
existingCopyMap.set(copy.source_screen_id, copy);
}
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
const screenDefsToProcess: Array<{ const screenDefsToProcess: Array<{
originalScreenId: number; originalScreenId: number;
@ -1303,35 +1364,20 @@ export class MenuCopyService {
for (const originalScreenId of screenIds) { for (const originalScreenId of screenIds) {
try { try {
// 1) 원본 screen_definitions 조회 // 1) 원본 screen_definitions 조회 (캐시에서)
const screenDefResult = await client.query<ScreenDefinition>( const screenDef = screenDefMap.get(originalScreenId);
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
[originalScreenId]
);
if (screenDefResult.rows.length === 0) { if (!screenDef) {
logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`);
continue; continue;
} }
const screenDef = screenDefResult.rows[0]; // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준)
let existingCopy = existingCopyMap.get(originalScreenId);
// 2) 기존 복사본 찾기: source_screen_id로 검색
let existingCopyResult = await client.query<{
screen_id: number;
screen_name: string;
updated_date: Date;
}>(
`SELECT screen_id, screen_name, updated_date
FROM screen_definitions
WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[originalScreenId, targetCompanyCode]
);
// 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지
if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { if (!existingCopy && screenDef.screen_name) {
existingCopyResult = await client.query<{ const legacyCopyResult = await client.query<{
screen_id: number; screen_id: number;
screen_name: string; screen_name: string;
updated_date: Date; updated_date: Date;
@ -1347,14 +1393,15 @@ export class MenuCopyService {
[screenDef.screen_name, screenDef.table_name, targetCompanyCode] [screenDef.screen_name, screenDef.table_name, targetCompanyCode]
); );
if (existingCopyResult.rows.length > 0) { if (legacyCopyResult.rows.length > 0) {
existingCopy = legacyCopyResult.rows[0];
// 기존 복사본에 source_screen_id 업데이트 (마이그레이션) // 기존 복사본에 source_screen_id 업데이트 (마이그레이션)
await client.query( await client.query(
`UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`,
[originalScreenId, existingCopyResult.rows[0].screen_id] [originalScreenId, existingCopy.screen_id]
); );
logger.info( logger.info(
` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id}${originalScreenId}` ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.screen_id}${originalScreenId}`
); );
} }
} }
@ -1375,10 +1422,9 @@ export class MenuCopyService {
} }
} }
if (existingCopyResult.rows.length > 0) { if (existingCopy) {
// === 기존 복사본이 있는 경우: 업데이트 === // === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreen = existingCopyResult.rows[0]; const existingScreenId = existingCopy.screen_id;
const existingScreenId = existingScreen.screen_id;
// 원본 레이아웃 조회 // 원본 레이아웃 조회
const sourceLayoutsResult = await client.query<ScreenLayout>( const sourceLayoutsResult = await client.query<ScreenLayout>(
@ -1526,12 +1572,18 @@ export class MenuCopyService {
// component_id 매핑 생성 (원본 → 새 ID) // component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>(); const componentIdMap = new Map<string, string>();
for (const layout of layoutsResult.rows) { const timestamp = Date.now();
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; layoutsResult.rows.forEach((layout, idx) => {
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
componentIdMap.set(layout.component_id, newComponentId); 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) { for (const layout of layoutsResult.rows) {
const newComponentId = componentIdMap.get(layout.component_id)!; const newComponentId = componentIdMap.get(layout.component_id)!;
@ -1549,13 +1601,10 @@ export class MenuCopyService {
numberingRuleIdMap numberingRuleIdMap
); );
await client.query( layoutValues.push(
`INSERT INTO screen_layouts ( `($${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})`
screen_id, component_type, component_id, parent_id, );
position_x, position_y, width, height, properties, layoutParams.push(
display_order, layout_type, layout_config, zones_config, zone_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
targetScreenId, targetScreenId,
layout.component_type, layout.component_type,
newComponentId, newComponentId,
@ -1569,8 +1618,19 @@ export class MenuCopyService {
layout.layout_type, layout.layout_type,
layout.layout_config, layout.layout_config,
layout.zones_config, layout.zones_config,
newZoneId, newZoneId
] );
paramIdx += 14;
}
// 배치 INSERT
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
); );
} }
@ -2185,6 +2245,101 @@ export class MenuCopyService {
return { copiedCategories, copiedCodes }; return { copiedCategories, copiedCodes };
} }
/**
*
* properties에서
* (rule_name)
*/
private async supplementNumberingRuleMapping(
screenIds: number[],
sourceCompanyCode: string,
targetCompanyCode: string,
numberingRuleIdMap: Map<string, string>,
client: PoolClient
): Promise<void> {
if (screenIds.length === 0) return;
// 1. 화면 레이아웃에서 모든 채번규칙 ID 추출
const layoutsResult = await client.query(
`SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`,
[screenIds]
);
const referencedRuleIds = new Set<string>();
const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g;
for (const row of layoutsResult.rows) {
if (!row.props) continue;
let match;
while ((match = ruleIdRegex.exec(row.props)) !== null) {
const ruleId = match[1];
// 이미 매핑된 것은 제외
if (ruleId && !numberingRuleIdMap.has(ruleId)) {
referencedRuleIds.add(ruleId);
}
}
}
if (referencedRuleIds.size === 0) {
logger.info(` 📭 추가 매핑 필요 없음`);
return;
}
logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}`);
// 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해)
const sourceRulesResult = await client.query(
`SELECT rule_id, rule_name, table_name FROM numbering_rules
WHERE rule_id = ANY($1)`,
[Array.from(referencedRuleIds)]
);
if (sourceRulesResult.rows.length === 0) {
logger.warn(
` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}`
);
return;
}
// 3. 대상 회사에서 같은 이름의 채번규칙 찾기
const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name);
const targetRulesResult = await client.query(
`SELECT rule_id, rule_name, table_name FROM numbering_rules
WHERE rule_name = ANY($1) AND company_code = $2`,
[ruleNames, targetCompanyCode]
);
// rule_name -> target_rule_id 매핑
const targetRulesByName = new Map<string, string>();
for (const r of targetRulesResult.rows) {
// 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용
if (!targetRulesByName.has(r.rule_name)) {
targetRulesByName.set(r.rule_name, r.rule_id);
}
}
// 4. 매핑 추가
let mappedCount = 0;
for (const sourceRule of sourceRulesResult.rows) {
const targetRuleId = targetRulesByName.get(sourceRule.rule_name);
if (targetRuleId) {
numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId);
logger.info(
` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}`
);
mappedCount++;
} else {
logger.warn(
` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}`
);
}
}
logger.info(
` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}`
);
}
/** /**
* (최적화: 배치 /) * (최적화: 배치 /)
* numberingRuleId * numberingRuleId
@ -2215,10 +2370,12 @@ export class MenuCopyService {
return { copiedCount, ruleIdMap }; return { copiedCount, ruleIdMap };
} }
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크) // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회
const ruleIds = allRulesResult.rows.map((r) => r.rule_id);
const existingRulesResult = await client.query( const existingRulesResult = await client.query(
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`, `SELECT rule_id FROM numbering_rules
[targetCompanyCode] WHERE rule_id = ANY($1) AND company_code = $2`,
[ruleIds, targetCompanyCode]
); );
const existingRuleIds = new Set( const existingRuleIds = new Set(
existingRulesResult.rows.map((r) => r.rule_id) existingRulesResult.rows.map((r) => r.rule_id)
@ -2228,21 +2385,29 @@ export class MenuCopyService {
const rulesToCopy: any[] = []; const rulesToCopy: any[] = [];
const originalToNewRuleMap: Array<{ original: string; new: string }> = []; const originalToNewRuleMap: Array<{ original: string; new: string }> = [];
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
for (const rule of allRulesResult.rows) { for (const rule of allRulesResult.rows) {
if (existingRuleIds.has(rule.rule_id)) {
// 기존 규칙은 동일한 ID로 매핑
ruleIdMap.set(rule.rule_id, rule.rule_id);
// 새 메뉴 ID로 연결 업데이트 필요
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (newMenuObjid) {
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
}
logger.info(
` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}`
);
} else {
// 새 rule_id 생성 // 새 rule_id 생성
const originalSuffix = rule.rule_id.includes("_") const originalSuffix = rule.rule_id.includes("_")
? rule.rule_id.replace(/^[^_]*_/, "") ? rule.rule_id.replace(/^[^_]*_/, "")
: rule.rule_id; : rule.rule_id;
const newRuleId = `${targetCompanyCode}_${originalSuffix}`; const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
// 원본 ID 또는 새로 생성될 ID가 이미 존재하는 경우 스킵
if (existingRuleIds.has(rule.rule_id)) {
ruleIdMap.set(rule.rule_id, rule.rule_id);
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
} else if (existingRuleIds.has(newRuleId)) {
ruleIdMap.set(rule.rule_id, newRuleId);
logger.info(` ♻️ 채번규칙 이미 존재 (대상 ID): ${newRuleId}`);
} else {
ruleIdMap.set(rule.rule_id, newRuleId); ruleIdMap.set(rule.rule_id, newRuleId);
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
rulesToCopy.push({ ...rule, newRuleId }); rulesToCopy.push({ ...rule, newRuleId });
@ -2288,8 +2453,30 @@ export class MenuCopyService {
copiedCount = rulesToCopy.length; copiedCount = rulesToCopy.length;
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`);
}
// 5. 모든 원본 파트 한 번에 조회 // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
if (rulesToUpdate.length > 0) {
// CASE WHEN을 사용한 배치 업데이트
const caseWhen = rulesToUpdate
.map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`)
.join(" ");
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
await client.query(
`UPDATE numbering_rules
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
[...params, ruleIdsForUpdate, targetCompanyCode]
);
logger.info(
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
);
}
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
if (rulesToCopy.length > 0) {
const originalRuleIds = rulesToCopy.map((r) => r.rule_id); const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
const allPartsResult = await client.query( const allPartsResult = await client.query(
`SELECT * FROM numbering_rule_parts `SELECT * FROM numbering_rule_parts
@ -2380,11 +2567,24 @@ export class MenuCopyService {
]) ])
); );
// 3. 복사할 매핑 필터링 및 배치 INSERT // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류
const mappingsToCopy = allMappingsResult.rows.filter( const mappingsToCopy: any[] = [];
(m) => const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> =
!existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) [];
);
for (const m of allMappingsResult.rows) {
const key = `${m.table_name}|${m.logical_column_name}`;
if (existingMappingKeys.has(key)) {
// 기존 매핑은 menu_objid만 업데이트
const existingMappingId = existingMappingKeys.get(key);
const newMenuObjid = menuIdMap.get(m.menu_objid);
if (existingMappingId && newMenuObjid) {
mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid });
}
} else {
mappingsToCopy.push(m);
}
}
// 새 매핑 ID -> 원본 매핑 정보 추적 // 새 매핑 ID -> 원본 매핑 정보 추적
const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = [];
@ -2433,6 +2633,29 @@ export class MenuCopyService {
logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`);
} }
// 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
if (mappingsToUpdate.length > 0) {
// CASE WHEN을 사용한 배치 업데이트
const caseWhen = mappingsToUpdate
.map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`)
.join(" ");
const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId);
const params = mappingsToUpdate.flatMap((m) => [
m.mappingId,
m.newMenuObjid,
]);
await client.query(
`UPDATE category_column_mapping
SET menu_objid = CASE ${caseWhen} END
WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
[...params, mappingIdsForUpdate, targetCompanyCode]
);
logger.info(
` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신`
);
}
// 4. 모든 원본 카테고리 값 한 번에 조회 // 4. 모든 원본 카테고리 값 한 번에 조회
const allValuesResult = await client.query( const allValuesResult = await client.query(
`SELECT * FROM table_column_category_values `SELECT * FROM table_column_category_values

View File

@ -916,7 +916,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "weather" ? ( ) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링 // 날씨 위젯 렌더링
<div className="widget-interactive-area h-full w-full"> <div className="widget-interactive-area h-full w-full">
<WeatherWidget city="서울" refreshInterval={600000} /> <WeatherWidget element={element} city="서울" refreshInterval={600000} />
</div> </div>
) : element.type === "widget" && element.subtype === "exchange" ? ( ) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링 // 환율 위젯 렌더링

View File

@ -2146,9 +2146,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Table> <Table>
<TableHeader className="bg-muted sticky top-0"> <TableHeader className="bg-muted sticky top-0">
<TableRow> <TableRow>
<TableHead className="w-[60px] text-xs"></TableHead> <TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm"></TableHead>
{(hierarchyConfig?.material?.displayColumns || []).map((col) => ( {(hierarchyConfig?.material?.displayColumns || []).map((col) => (
<TableHead key={col.column} className="text-xs"> <TableHead key={col.column} className="px-3 py-3 text-sm">
{col.label} {col.label}
</TableHead> </TableHead>
))} ))}
@ -2163,9 +2163,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
return ( return (
<TableRow key={material[keyColumn] || `material-${index}`}> <TableRow key={material[keyColumn] || `material-${index}`}>
<TableCell className="text-xs font-medium">{layerNumber}</TableCell> <TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}</TableCell>
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<TableCell key={col.column} className="text-xs"> <TableCell key={col.column} className="px-3 py-3 text-sm">
{material[col.column] || "-"} {material[col.column] || "-"}
</TableCell> </TableCell>
))} ))}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { apiCall } from "@/lib/api/client";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false, ssr: false,
@ -26,6 +27,9 @@ interface DigitalTwinViewerProps {
layoutId: number; layoutId: number;
} }
// 외부 업체 역할 코드
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
const { toast } = useToast(); const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]); const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
@ -43,6 +47,73 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const [filterType, setFilterType] = useState<string>("all"); const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
// 외부 업체 모드
const [isExternalMode, setIsExternalMode] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null); // 마지막 갱신 시간
const canvasContainerRef = useRef<HTMLDivElement>(null);
// 외부 업체 역할 체크
useEffect(() => {
const checkExternalRole = async () => {
try {
const response = await apiCall<any[]>("GET", "/roles/user/my-groups");
console.log("=== 사용자 권한 그룹 조회 ===");
console.log("API 응답:", response);
console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE);
if (response.success && response.data) {
console.log("권한 그룹 목록:", response.data);
// 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인
const hasExternalRole = response.data.some((group: any) => {
console.log("체크 중인 그룹:", group.authCode, group.authName);
return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE;
});
console.log("외부 업체 역할 보유:", hasExternalRole);
setIsExternalMode(hasExternalRole);
}
} catch (error) {
console.error("역할 조회 실패:", error);
}
};
checkExternalRole();
}, []);
// 전체 화면 토글 (3D 캔버스 영역만)
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
// 3D 캔버스 컨테이너만 풀스크린
canvasContainerRef.current?.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
// 전체 화면 변경 감지
useEffect(() => {
const handleFullscreenChange = () => {
const isNowFullscreen = !!document.fullscreenElement;
setIsFullscreen(isNowFullscreen);
// 전체화면 종료 시 레이아웃 강제 리렌더링
if (!isNowFullscreen) {
setTimeout(() => {
setLayoutKey((prev) => prev + 1);
window.dispatchEvent(new Event("resize"));
}, 50);
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
// 레이아웃 데이터 로드 함수 // 레이아웃 데이터 로드 함수
const loadLayout = async () => { const loadLayout = async () => {
try { try {
@ -144,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}), }),
); );
} }
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
} else { } else {
throw new Error(response.error || "레이아웃 조회 실패"); throw new Error(response.error || "레이아웃 조회 실패");
} }
@ -180,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); }, [layoutId]);
// 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침)
useEffect(() => {
const AUTO_REFRESH_INTERVAL = 10000; // 10초
const silentRefresh = async () => {
// 로딩 중이거나 새로고침 중이면 스킵
if (isLoading || isRefreshing) return;
try {
// 레이아웃 데이터 조용히 갱신
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
// hierarchy_config 파싱
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType, obj.color),
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
});
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey,
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch {
// 자동 갱신 시에는 에러 로그 생략
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
const updatedObjects = loadedObjects.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
});
setPlacedObjects(updatedObjects);
} else {
setPlacedObjects(loadedObjects);
}
// 선택된 객체가 있으면 자재 목록도 갱신
if (selectedObject && dbConnectionId && hierarchyConfigData?.material) {
const currentObj = loadedObjects.find((o) => o.id === selectedObject.id);
if (
currentObj &&
(currentObj.type === "location-bed" ||
currentObj.type === "location-temp" ||
currentObj.type === "location-dest") &&
currentObj.locaKey
) {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: currentObj.locaKey,
});
if (matResponse.success && matResponse.data) {
const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER";
const sortedMaterials = matResponse.data.sort(
(a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0),
);
setMaterials(sortedMaterials);
}
}
}
// 마지막 갱신 시간 기록
setLastRefreshedAt(new Date());
}
} catch {
// 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함)
}
};
// 10초마다 자동 갱신
const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL);
// 컴포넌트 언마운트 시 인터벌 정리
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId, isLoading, isRefreshing, selectedObject]);
// Location의 자재 목록 로드 // Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
if (!hierarchyConfig?.material) { if (!hierarchyConfig?.material) {
@ -200,7 +422,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
}); });
if (response.success && response.data) { if (response.success && response.data) {
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER"; const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0)); // 층 내림차순 정렬 (높은 층이 위로)
const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0));
setMaterials(sortedMaterials); setMaterials(sortedMaterials);
} else { } else {
setMaterials([]); setMaterials([]);
@ -334,8 +557,28 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="flex items-center justify-between border-b p-4"> <div className="flex items-center justify-between border-b p-4">
<div> <div>
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2> <h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p> <div className="flex items-center gap-3">
<p className="text-muted-foreground text-sm">{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}</p>
{lastRefreshedAt && (
<span className="text-muted-foreground text-xs">
: {lastRefreshedAt.toLocaleTimeString("ko-KR")}
</span>
)}
</div> </div>
</div>
<div className="flex items-center gap-2">
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}
{isExternalMode && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
title={isFullscreen ? "전체 화면 종료" : "전체 화면"}
>
{isFullscreen ? <Minimize className="mr-2 h-4 w-4" /> : <Maximize className="mr-2 h-4 w-4" />}
{isFullscreen ? "종료" : "전체 화면"}
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -347,10 +590,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{isRefreshing ? "갱신 중..." : "새로고침"} {isRefreshing ? "갱신 중..." : "새로고침"}
</Button> </Button>
</div> </div>
</div>
{/* 메인 영역 */} {/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */} {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
{!isExternalMode && (
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r"> <div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* 검색 */} {/* 검색 */}
@ -575,9 +820,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)} )}
</div> </div>
</div> </div>
)}
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
<div
ref={canvasContainerRef}
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
>
{/* 중앙: 3D 캔버스 */} {/* 중앙: 3D 캔버스 */}
<div className="relative flex-1"> <div className="relative min-w-0 flex-1">
{!isLoading && ( {!isLoading && (
<Yard3DCanvas <Yard3DCanvas
placements={canvasPlacements} placements={canvasPlacements}
@ -590,7 +841,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
{/* 우측: 정보 패널 */} {/* 우측: 정보 패널 */}
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l"> <div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
{selectedObject ? ( {selectedObject ? (
<div className="p-4"> <div className="p-4">
<div className="mb-4"> <div className="mb-4">
@ -640,20 +891,15 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<Label className="mb-2 block text-sm font-semibold"> <Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
({materials.length})
</Label>
{/* 테이블 형태로 전체 조회 */} {/* 테이블 형태로 전체 조회 */}
<div className="max-h-[400px] overflow-auto rounded-lg border"> <div className="h-[580px] overflow-auto rounded-lg border">
<table className="w-full text-xs"> <table className="w-full text-sm">
<thead className="bg-muted sticky top-0"> <thead className="bg-muted sticky top-0">
<tr> <tr>
<th className="border-b px-2 py-2 text-left font-semibold"></th> <th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap"></th>
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
<th <th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
key={colConfig.column}
className="border-b px-2 py-2 text-left font-semibold"
>
{colConfig.label} {colConfig.label}
</th> </th>
))} ))}
@ -668,11 +914,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
key={`${material.STKKEY}-${index}`} key={`${material.STKKEY}-${index}`}
className="hover:bg-accent border-b transition-colors last:border-0" className="hover:bg-accent border-b transition-colors last:border-0"
> >
<td className="px-2 py-2 font-medium"> <td className="px-3 py-3 font-medium whitespace-nowrap">
{material[layerColumn]} {material[layerColumn]}
</td> </td>
{displayColumns.map((colConfig: any) => ( {displayColumns.map((colConfig: any) => (
<td key={colConfig.column} className="px-2 py-2"> <td key={colConfig.column} className="px-3 py-3">
{material[colConfig.column] || "-"} {material[colConfig.column] || "-"}
</td> </td>
))} ))}
@ -693,6 +939,20 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div> </div>
)} )}
</div> </div>
{/* 풀스크린 모드일 때 종료 버튼 */}
{isFullscreen && (
<Button
variant="outline"
size="sm"
onClick={toggleFullscreen}
className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm"
>
<Minimize className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</div> </div>
); );