Merge main into feature/screen-management (menuCopyService 충돌 해결)
This commit is contained in:
commit
09f477172c
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -299,18 +299,16 @@ 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 = ANY($1) AND company_code = $2`,
|
||||||
WHERE menu_objid = $1 AND company_code = $2`,
|
[menuObjids, sourceCompanyCode]
|
||||||
[menuObjid, 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,50 +369,56 @@ 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;
|
||||||
);
|
}
|
||||||
|
|
||||||
for (const layout of layoutsResult.rows) {
|
const layoutsResult = await client.query<
|
||||||
const props = layout.properties;
|
ScreenLayout & { screen_id: number }
|
||||||
|
>(
|
||||||
|
`SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`,
|
||||||
|
[screenIdArray]
|
||||||
|
);
|
||||||
|
|
||||||
// webTypeConfig.dataflowConfig.flowConfig.flowId
|
for (const layout of layoutsResult.rows) {
|
||||||
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
const props = layout.properties;
|
||||||
const flowName =
|
const screenId = layout.screen_id;
|
||||||
props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName ||
|
|
||||||
"Unknown";
|
|
||||||
|
|
||||||
if (flowId && typeof flowId === "number" && flowId > 0) {
|
// webTypeConfig.dataflowConfig.flowConfig.flowId
|
||||||
if (!flowIds.has(flowId)) {
|
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
||||||
flowIds.add(flowId);
|
const flowName =
|
||||||
flowDetails.push({ flowId, flowName, screenId });
|
props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown";
|
||||||
logger.info(
|
|
||||||
` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`
|
if (flowId && typeof flowId === "number" && flowId > 0) {
|
||||||
);
|
if (!flowIds.has(flowId)) {
|
||||||
}
|
flowIds.add(flowId);
|
||||||
|
flowDetails.push({ flowId, flowName, screenId });
|
||||||
|
logger.info(
|
||||||
|
` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음)
|
// selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음)
|
||||||
const selectedDiagramId =
|
const selectedDiagramId =
|
||||||
props?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
|
props?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
|
||||||
if (
|
if (
|
||||||
selectedDiagramId &&
|
selectedDiagramId &&
|
||||||
typeof selectedDiagramId === "number" &&
|
typeof selectedDiagramId === "number" &&
|
||||||
selectedDiagramId > 0
|
selectedDiagramId > 0
|
||||||
) {
|
) {
|
||||||
if (!flowIds.has(selectedDiagramId)) {
|
if (!flowIds.has(selectedDiagramId)) {
|
||||||
flowIds.add(selectedDiagramId);
|
flowIds.add(selectedDiagramId);
|
||||||
flowDetails.push({
|
flowDetails.push({
|
||||||
flowId: selectedDiagramId,
|
flowId: selectedDiagramId,
|
||||||
flowName: "SelectedDiagram",
|
flowName: "SelectedDiagram",
|
||||||
screenId,
|
screenId,
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`
|
` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,47 +756,53 @@ 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
|
||||||
AND (scope_type IS NULL OR scope_type != 'menu')
|
AND (scope_type IS NULL OR scope_type != 'menu')
|
||||||
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 사용)
|
||||||
await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [
|
// 외래키 제약이 해제되었으므로 배치 삭제 가능
|
||||||
existingMenuIds,
|
if (existingMenuIds.length > 0) {
|
||||||
]);
|
await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [
|
||||||
|
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,36 +1572,39 @@ 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);
|
||||||
}
|
});
|
||||||
|
|
||||||
// 레이아웃 삽입
|
// 레이아웃 배치 삽입 준비
|
||||||
for (const layout of layoutsResult.rows) {
|
if (layoutsResult.rows.length > 0) {
|
||||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
const layoutValues: string[] = [];
|
||||||
|
const layoutParams: any[] = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
const newParentId = layout.parent_id
|
for (const layout of layoutsResult.rows) {
|
||||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||||
: null;
|
|
||||||
const newZoneId = layout.zone_id
|
|
||||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const updatedProperties = this.updateReferencesInProperties(
|
const newParentId = layout.parent_id
|
||||||
layout.properties,
|
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||||
screenIdMap,
|
: null;
|
||||||
flowIdMap,
|
const newZoneId = layout.zone_id
|
||||||
numberingRuleIdMap
|
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||||
);
|
: null;
|
||||||
|
|
||||||
await client.query(
|
const updatedProperties = this.updateReferencesInProperties(
|
||||||
`INSERT INTO screen_layouts (
|
layout.properties,
|
||||||
screen_id, component_type, component_id, parent_id,
|
screenIdMap,
|
||||||
position_x, position_y, width, height, properties,
|
flowIdMap,
|
||||||
display_order, layout_type, layout_config, zones_config, zone_id
|
numberingRuleIdMap
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
);
|
||||||
[
|
|
||||||
|
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,
|
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 }> = [];
|
||||||
|
|
||||||
for (const rule of allRulesResult.rows) {
|
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
|
||||||
// 새 rule_id 생성
|
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
|
||||||
const originalSuffix = rule.rule_id.includes("_")
|
|
||||||
? rule.rule_id.replace(/^[^_]*_/, "")
|
|
||||||
: rule.rule_id;
|
|
||||||
const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
|
|
||||||
|
|
||||||
// 원본 ID 또는 새로 생성될 ID가 이미 존재하는 경우 스킵
|
for (const rule of allRulesResult.rows) {
|
||||||
if (existingRuleIds.has(rule.rule_id)) {
|
if (existingRuleIds.has(rule.rule_id)) {
|
||||||
|
// 기존 규칙은 동일한 ID로 매핑
|
||||||
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
||||||
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
|
|
||||||
} else if (existingRuleIds.has(newRuleId)) {
|
// 새 메뉴 ID로 연결 업데이트 필요
|
||||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||||
logger.info(` ♻️ 채번규칙 이미 존재 (대상 ID): ${newRuleId}`);
|
if (newMenuObjid) {
|
||||||
|
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// 새 rule_id 생성
|
||||||
|
const originalSuffix = rule.rule_id.includes("_")
|
||||||
|
? rule.rule_id.replace(/^[^_]*_/, "")
|
||||||
|
: rule.rule_id;
|
||||||
|
const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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" ? (
|
||||||
// 환율 위젯 렌더링
|
// 환율 위젯 렌더링
|
||||||
|
|
|
||||||
|
|
@ -2146,32 +2146,32 @@ 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>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{materials.map((material, index) => {
|
{materials.map((material, index) => {
|
||||||
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
||||||
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
|
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
|
||||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||||
const layerNumber = material[layerColumn] || index + 1;
|
const layerNumber = material[layerColumn] || index + 1;
|
||||||
|
|
||||||
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>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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,363 +557,400 @@ 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 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
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing || isLoading}
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
|
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isRefreshing || isLoading}
|
|
||||||
title="새로고침"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
|
||||||
{isRefreshing ? "갱신 중..." : "새로고침"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측: 검색/필터 */}
|
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
|
||||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
{!isExternalMode && (
|
||||||
<div className="space-y-4 p-4">
|
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
||||||
{/* 검색 */}
|
<div className="space-y-4 p-4">
|
||||||
<div>
|
{/* 검색 */}
|
||||||
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
<div>
|
||||||
<div className="relative">
|
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<div className="relative">
|
||||||
<Input
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
value={searchQuery}
|
<Input
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
placeholder="이름, Area, Location 검색..."
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="h-10 pl-9 text-sm"
|
placeholder="이름, Area, Location 검색..."
|
||||||
/>
|
className="h-10 pl-9 text-sm"
|
||||||
{searchQuery && (
|
/>
|
||||||
<Button
|
{searchQuery && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
size="sm"
|
||||||
onClick={() => setSearchQuery("")}
|
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
||||||
>
|
onClick={() => setSearchQuery("")}
|
||||||
<X className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<X className="h-3 w-3" />
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 타입 필터 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-10 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||||
|
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||||
|
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||||
|
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||||
|
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||||
|
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||||
|
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||||
|
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 초기화 */}
|
||||||
|
{(searchQuery || filterType !== "all") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-full text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setFilterType("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
필터 초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타입 필터 */}
|
{/* 객체 목록 */}
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||||
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
||||||
<Select value={filterType} onValueChange={setFilterType}>
|
{filteredObjects.length === 0 ? (
|
||||||
<SelectTrigger className="h-10 text-sm">
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
<SelectValue />
|
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
) : (
|
||||||
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
(() => {
|
||||||
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
||||||
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
||||||
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
|
||||||
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
|
||||||
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
|
||||||
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
|
||||||
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필터 초기화 */}
|
// Area가 없으면 기존 평면 리스트 유지
|
||||||
{(searchQuery || filterType !== "all") && (
|
if (areaObjects.length === 0) {
|
||||||
<Button
|
return (
|
||||||
variant="outline"
|
<div className="space-y-2">
|
||||||
size="sm"
|
{filteredObjects.map((obj) => {
|
||||||
className="h-9 w-full text-sm"
|
let typeLabel = obj.type;
|
||||||
onClick={() => {
|
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||||
setSearchQuery("");
|
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||||
setFilterType("all");
|
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||||
}}
|
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||||
>
|
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||||
필터 초기화
|
else if (obj.type === "area") typeLabel = "Area";
|
||||||
</Button>
|
else if (obj.type === "rack") typeLabel = "랙";
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 객체 목록 */}
|
return (
|
||||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
<div
|
||||||
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
key={obj.id}
|
||||||
{filteredObjects.length === 0 ? (
|
onClick={() => handleObjectClick(obj.id)}
|
||||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||||
</div>
|
}`}
|
||||||
) : (
|
>
|
||||||
(() => {
|
<div className="flex items-start justify-between">
|
||||||
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
<div className="flex-1">
|
||||||
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
<p className="text-sm font-medium">{obj.name}</p>
|
||||||
|
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||||
// Area가 없으면 기존 평면 리스트 유지
|
<span
|
||||||
if (areaObjects.length === 0) {
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
return (
|
style={{ backgroundColor: obj.color }}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
{filteredObjects.map((obj) => {
|
<span>{typeLabel}</span>
|
||||||
let typeLabel = obj.type;
|
</div>
|
||||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
|
||||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
|
||||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
|
||||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
|
||||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
|
||||||
else if (obj.type === "area") typeLabel = "Area";
|
|
||||||
else if (obj.type === "rack") typeLabel = "랙";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={obj.id}
|
|
||||||
onClick={() => handleObjectClick(obj.id)}
|
|
||||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
|
||||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium">{obj.name}</p>
|
|
||||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: obj.color }}
|
|
||||||
/>
|
|
||||||
<span>{typeLabel}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{obj.areaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.locaKey && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||||
|
<p className="text-xs text-yellow-600">
|
||||||
|
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 space-y-1">
|
);
|
||||||
{obj.areaKey && (
|
})}
|
||||||
<p className="text-muted-foreground text-xs">
|
</div>
|
||||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
);
|
||||||
</p>
|
}
|
||||||
|
|
||||||
|
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||||
|
return (
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{areaObjects.map((areaObj) => {
|
||||||
|
const childLocations = filteredObjects.filter(
|
||||||
|
(obj) =>
|
||||||
|
obj.type !== "area" &&
|
||||||
|
obj.areaKey === areaObj.areaKey &&
|
||||||
|
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||||
|
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||||
|
<div
|
||||||
|
className={`flex w-full items-center justify-between pr-2 ${
|
||||||
|
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleObjectClick(areaObj.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Grid3x3 className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: areaObj.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-2 pb-3">
|
||||||
|
{childLocations.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{childLocations.map((locationObj) => (
|
||||||
|
<div
|
||||||
|
key={locationObj.id}
|
||||||
|
onClick={() => handleObjectClick(locationObj.id)}
|
||||||
|
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||||
|
selectedObject?.id === locationObj.id
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{locationObj.type === "location-stp" ? (
|
||||||
|
<ParkingCircle className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Package className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: locationObj.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||||
|
</p>
|
||||||
|
{locationObj.locaKey && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||||
|
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-yellow-600">
|
||||||
|
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{obj.locaKey && (
|
</AccordionContent>
|
||||||
<p className="text-muted-foreground text-xs">
|
</AccordionItem>
|
||||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
|
||||||
<p className="text-xs text-yellow-600">
|
|
||||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Accordion>
|
||||||
);
|
);
|
||||||
}
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
|
||||||
return (
|
<div
|
||||||
<Accordion type="multiple" className="w-full">
|
ref={canvasContainerRef}
|
||||||
{areaObjects.map((areaObj) => {
|
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
|
||||||
const childLocations = filteredObjects.filter(
|
>
|
||||||
(obj) =>
|
{/* 중앙: 3D 캔버스 */}
|
||||||
obj.type !== "area" &&
|
<div className="relative min-w-0 flex-1">
|
||||||
obj.areaKey === areaObj.areaKey &&
|
{!isLoading && (
|
||||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
<Yard3DCanvas
|
||||||
);
|
placements={canvasPlacements}
|
||||||
|
selectedPlacementId={selectedObject?.id || null}
|
||||||
return (
|
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
focusOnPlacementId={null}
|
||||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
onCollisionDetected={() => {}}
|
||||||
<div
|
/>
|
||||||
className={`flex w-full items-center justify-between pr-2 ${
|
|
||||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleObjectClick(areaObj.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Grid3x3 className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: areaObj.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-2 pb-3">
|
|
||||||
{childLocations.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{childLocations.map((locationObj) => (
|
|
||||||
<div
|
|
||||||
key={locationObj.id}
|
|
||||||
onClick={() => handleObjectClick(locationObj.id)}
|
|
||||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
|
||||||
selectedObject?.id === locationObj.id
|
|
||||||
? "border-primary bg-primary/10"
|
|
||||||
: "hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{locationObj.type === "location-stp" ? (
|
|
||||||
<ParkingCircle className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<Package className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
||||||
style={{ backgroundColor: locationObj.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
||||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
|
||||||
</p>
|
|
||||||
{locationObj.locaKey && (
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
|
||||||
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
|
||||||
<p className="mt-0.5 text-[10px] text-yellow-600">
|
|
||||||
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 중앙: 3D 캔버스 */}
|
{/* 우측: 정보 패널 */}
|
||||||
<div className="relative flex-1">
|
<div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||||
{!isLoading && (
|
{selectedObject ? (
|
||||||
<Yard3DCanvas
|
<div className="p-4">
|
||||||
placements={canvasPlacements}
|
<div className="mb-4">
|
||||||
selectedPlacementId={selectedObject?.id || null}
|
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||||
focusOnPlacementId={null}
|
|
||||||
onCollisionDetected={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 우측: 정보 패널 */}
|
|
||||||
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
|
||||||
{selectedObject ? (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">상세 정보</h3>
|
|
||||||
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<div className="bg-muted space-y-3 rounded-lg p-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground text-xs">타입</Label>
|
|
||||||
<p className="text-sm font-medium">{selectedObject.type}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{selectedObject.areaKey && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
|
||||||
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedObject.locaKey && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
|
||||||
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
|
||||||
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 자재 목록 (Location인 경우) - 테이블 형태 */}
|
{/* 기본 정보 */}
|
||||||
{(selectedObject.type === "location-bed" ||
|
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||||
selectedObject.type === "location-stp" ||
|
<div>
|
||||||
selectedObject.type === "location-temp" ||
|
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||||
selectedObject.type === "location-dest") && (
|
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||||
<div className="mt-4">
|
</div>
|
||||||
{loadingMaterials ? (
|
{selectedObject.areaKey && (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div>
|
||||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||||
</div>
|
</div>
|
||||||
) : materials.length === 0 ? (
|
)}
|
||||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
{selectedObject.locaKey && (
|
||||||
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div className="space-y-2">
|
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||||
<Label className="mb-2 block text-sm font-semibold">
|
<div>
|
||||||
자재 목록 ({materials.length}개)
|
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||||
</Label>
|
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||||
{/* 테이블 형태로 전체 조회 */}
|
|
||||||
<div className="max-h-[400px] overflow-auto rounded-lg border">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="border-b px-2 py-2 text-left font-semibold">층</th>
|
|
||||||
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
|
||||||
<th
|
|
||||||
key={colConfig.column}
|
|
||||||
className="border-b px-2 py-2 text-left font-semibold"
|
|
||||||
>
|
|
||||||
{colConfig.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{materials.map((material, index) => {
|
|
||||||
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
|
||||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${material.STKKEY}-${index}`}
|
|
||||||
className="hover:bg-accent border-b transition-colors last:border-0"
|
|
||||||
>
|
|
||||||
<td className="px-2 py-2 font-medium">
|
|
||||||
{material[layerColumn]}단
|
|
||||||
</td>
|
|
||||||
{displayColumns.map((colConfig: any) => (
|
|
||||||
<td key={colConfig.column} className="px-2 py-2">
|
|
||||||
{material[colConfig.column] || "-"}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* 자재 목록 (Location인 경우) - 테이블 형태 */}
|
||||||
) : (
|
{(selectedObject.type === "location-bed" ||
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
selectedObject.type === "location-stp" ||
|
||||||
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
selectedObject.type === "location-temp" ||
|
||||||
</div>
|
selectedObject.type === "location-dest") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{loadingMaterials ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : materials.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
|
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
||||||
|
{/* 테이블 형태로 전체 조회 */}
|
||||||
|
<div className="h-[580px] overflow-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap">층</th>
|
||||||
|
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
||||||
|
<th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
|
||||||
|
{colConfig.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{materials.map((material, index) => {
|
||||||
|
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
||||||
|
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${material.STKKEY}-${index}`}
|
||||||
|
className="hover:bg-accent border-b transition-colors last:border-0"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-3 font-medium whitespace-nowrap">
|
||||||
|
{material[layerColumn]}단
|
||||||
|
</td>
|
||||||
|
{displayColumns.map((colConfig: any) => (
|
||||||
|
<td key={colConfig.column} className="px-3 py-3">
|
||||||
|
{material[colConfig.column] || "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -274,15 +274,15 @@ export function QueryManager() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => handleDeleteQuery(query.id, e)}
|
onClick={(e) => handleDeleteQuery(query.id, e)}
|
||||||
className="h-7 w-7 shrink-0 p-0"
|
className="h-7 w-7 shrink-0 p-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
||||||
{/* 쿼리 이름 */}
|
{/* 쿼리 이름 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -486,11 +486,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return component;
|
return component;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return { ...page, components: componentsWithBase64 };
|
return { ...page, components: componentsWithBase64 };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 쿼리 결과 수집
|
// 쿼리 결과 수집
|
||||||
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
|
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue