화면 일괄삭제기능
This commit is contained in:
parent
8317af92cd
commit
eb5ea411c9
|
|
@ -1428,10 +1428,51 @@ export async function deleteMenu(
|
|||
}
|
||||
}
|
||||
|
||||
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||
const menuObjid = Number(menuId);
|
||||
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||
await query(
|
||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||
await query(
|
||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||
|
||||
// Raw Query를 사용한 메뉴 삭제
|
||||
const [deletedMenu] = await query<any>(
|
||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||
[Number(menuId)]
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||
|
|
|
|||
|
|
@ -325,6 +325,53 @@ export const getDeletedScreens = async (
|
|||
}
|
||||
};
|
||||
|
||||
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
export const bulkDeleteScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { screenIds, deleteReason, force } = req.body;
|
||||
|
||||
if (!Array.isArray(screenIds) || screenIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "삭제할 화면 ID 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.bulkDeleteScreens(
|
||||
screenIds,
|
||||
companyCode,
|
||||
userId,
|
||||
deleteReason,
|
||||
force || false
|
||||
);
|
||||
|
||||
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||
if (result.skippedCount > 0) {
|
||||
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message,
|
||||
result: {
|
||||
deletedCount: result.deletedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
errors: result.errors,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("활성 화면 일괄 삭제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "일괄 삭제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 휴지통 화면 일괄 영구 삭제
|
||||
export const bulkPermanentDeleteScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
updateScreen,
|
||||
updateScreenInfo,
|
||||
deleteScreen,
|
||||
bulkDeleteScreens,
|
||||
checkScreenDependencies,
|
||||
restoreScreen,
|
||||
permanentDeleteScreen,
|
||||
|
|
@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen);
|
|||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ interface ScreenDefinition {
|
|||
layout_metadata: any;
|
||||
db_source_type: string | null;
|
||||
db_connection_id: number | null;
|
||||
source_screen_id: number | null; // 원본 화면 ID (복사 추적용)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -234,6 +235,27 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId)
|
||||
if (props?.componentConfig?.leftScreenId) {
|
||||
const leftScreenId = props.componentConfig.leftScreenId;
|
||||
const numId =
|
||||
typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId);
|
||||
if (!isNaN(numId) && numId > 0) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (props?.componentConfig?.rightScreenId) {
|
||||
const rightScreenId = props.componentConfig.rightScreenId;
|
||||
const numId =
|
||||
typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId);
|
||||
if (!isNaN(numId) && numId > 0) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
|
|
@ -431,14 +453,16 @@ export class MenuCopyService {
|
|||
const value = obj[key];
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||
if (
|
||||
key === "screen_id" ||
|
||||
key === "screenId" ||
|
||||
key === "targetScreenId"
|
||||
key === "targetScreenId" ||
|
||||
key === "leftScreenId" ||
|
||||
key === "rightScreenId"
|
||||
) {
|
||||
const numValue = typeof value === "number" ? value : parseInt(value);
|
||||
if (!isNaN(numValue)) {
|
||||
if (!isNaN(numValue) && numValue > 0) {
|
||||
const newId = screenIdMap.get(numValue);
|
||||
if (newId) {
|
||||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||||
|
|
@ -856,7 +880,10 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면 복사
|
||||
* 화면 복사 (업데이트 또는 신규 생성)
|
||||
* - source_screen_id로 기존 복사본 찾기
|
||||
* - 변경된 내용이 있으면 업데이트
|
||||
* - 없으면 새로 복사
|
||||
*/
|
||||
private async copyScreens(
|
||||
screenIds: Set<number>,
|
||||
|
|
@ -876,18 +903,19 @@ export class MenuCopyService {
|
|||
return screenIdMap;
|
||||
}
|
||||
|
||||
logger.info(`📄 화면 복사 중: ${screenIds.size}개`);
|
||||
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`);
|
||||
|
||||
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
|
||||
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
|
||||
const screenDefsToProcess: Array<{
|
||||
originalScreenId: number;
|
||||
newScreenId: number;
|
||||
targetScreenId: number;
|
||||
screenDef: ScreenDefinition;
|
||||
isUpdate: boolean; // 업데이트인지 신규 생성인지
|
||||
}> = [];
|
||||
|
||||
for (const originalScreenId of screenIds) {
|
||||
try {
|
||||
// 1) screen_definitions 조회
|
||||
// 1) 원본 screen_definitions 조회
|
||||
const screenDefResult = await client.query<ScreenDefinition>(
|
||||
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
|
|
@ -900,72 +928,134 @@ export class MenuCopyService {
|
|||
|
||||
const screenDef = screenDefResult.rows[0];
|
||||
|
||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
// 2) 기존 복사본 찾기: source_screen_id로 검색
|
||||
const 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`,
|
||||
[screenDef.screen_code, targetCompanyCode]
|
||||
[originalScreenId, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreenResult.rows.length > 0) {
|
||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
||||
);
|
||||
continue; // 레이아웃 복사도 스킵
|
||||
}
|
||||
|
||||
// 3) 새 screen_code 생성
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// 4) 화면명 변환 적용
|
||||
// 3) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
// 1. 제거할 텍스트 제거
|
||||
if (screenNameConfig.removeText?.trim()) {
|
||||
transformedScreenName = transformedScreenName.replace(
|
||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||
""
|
||||
);
|
||||
transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거
|
||||
transformedScreenName = transformedScreenName.trim();
|
||||
}
|
||||
|
||||
// 2. 접두사 추가
|
||||
if (screenNameConfig.addPrefix?.trim()) {
|
||||
transformedScreenName =
|
||||
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
if (existingCopyResult.rows.length > 0) {
|
||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreen = existingCopyResult.rows[0];
|
||||
const existingScreenId = existingScreen.screen_id;
|
||||
|
||||
// 원본 레이아웃 조회
|
||||
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 대상 레이아웃 조회
|
||||
const targetLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
||||
const hasChanges = this.hasLayoutChanges(
|
||||
sourceLayoutsResult.rows,
|
||||
targetLayoutsResult.rows
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
// 변경 사항이 있으면 업데이트
|
||||
logger.info(
|
||||
` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
|
||||
// screen_definitions 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_definitions SET
|
||||
screen_name = $1,
|
||||
table_name = $2,
|
||||
description = $3,
|
||||
is_active = $4,
|
||||
layout_metadata = $5,
|
||||
db_source_type = $6,
|
||||
db_connection_id = $7,
|
||||
updated_by = $8,
|
||||
updated_date = NOW()
|
||||
WHERE screen_id = $9`,
|
||||
[
|
||||
transformedScreenName,
|
||||
screenDef.table_name,
|
||||
screenDef.description,
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||
screenDef.layout_metadata,
|
||||
screenDef.db_source_type,
|
||||
screenDef.db_connection_id,
|
||||
userId,
|
||||
existingScreenId,
|
||||
]
|
||||
);
|
||||
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
screenDefsToProcess.push({
|
||||
originalScreenId,
|
||||
targetScreenId: existingScreenId,
|
||||
screenDef,
|
||||
isUpdate: true,
|
||||
});
|
||||
} else {
|
||||
// 변경 사항이 없으면 스킵
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// === 기존 복사본이 없는 경우: 신규 생성 ===
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
description, is_active, layout_metadata,
|
||||
db_source_type, db_connection_id, created_by,
|
||||
deleted_date, deleted_by, delete_reason
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
deleted_date, deleted_by, delete_reason, source_screen_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING screen_id`,
|
||||
[
|
||||
transformedScreenName, // 변환된 화면명
|
||||
newScreenCode, // 새 화면 코드
|
||||
transformedScreenName,
|
||||
newScreenCode,
|
||||
screenDef.table_name,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
targetCompanyCode,
|
||||
screenDef.description,
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화
|
||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
||||
screenDef.layout_metadata,
|
||||
screenDef.db_source_type,
|
||||
screenDef.db_connection_id,
|
||||
userId,
|
||||
null, // deleted_date: NULL (새 화면은 삭제되지 않음)
|
||||
null, // deleted_by: NULL
|
||||
null, // delete_reason: NULL
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
originalScreenId, // source_screen_id 저장
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -973,49 +1063,63 @@ export class MenuCopyService {
|
|||
screenIdMap.set(originalScreenId, newScreenId);
|
||||
|
||||
logger.info(
|
||||
` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||
` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||
);
|
||||
|
||||
// 저장해서 2단계에서 처리
|
||||
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
|
||||
screenDefsToProcess.push({
|
||||
originalScreenId,
|
||||
targetScreenId: newScreenId,
|
||||
screenDef,
|
||||
isUpdate: false,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
|
||||
`❌ 화면 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
||||
logger.info(
|
||||
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
|
||||
for (const {
|
||||
originalScreenId,
|
||||
newScreenId,
|
||||
targetScreenId,
|
||||
screenDef,
|
||||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// screen_layouts 복사
|
||||
// 원본 레이아웃 조회
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
|
||||
if (isUpdate) {
|
||||
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
||||
[targetScreenId]
|
||||
);
|
||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||
}
|
||||
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
componentIdMap.set(layout.component_id, newComponentId);
|
||||
}
|
||||
|
||||
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
|
||||
// 레이아웃 삽입
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||
|
||||
// parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우)
|
||||
const newParentId = layout.parent_id
|
||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||
: null;
|
||||
|
|
@ -1023,7 +1127,6 @@ export class MenuCopyService {
|
|||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||
: null;
|
||||
|
||||
// properties 내부 참조 업데이트
|
||||
const updatedProperties = this.updateReferencesInProperties(
|
||||
layout.properties,
|
||||
screenIdMap,
|
||||
|
|
@ -1037,38 +1140,94 @@ export class MenuCopyService {
|
|||
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)`,
|
||||
[
|
||||
newScreenId, // 새 화면 ID
|
||||
targetScreenId,
|
||||
layout.component_type,
|
||||
newComponentId, // 새 컴포넌트 ID
|
||||
newParentId, // 매핑된 parent_id
|
||||
newComponentId,
|
||||
newParentId,
|
||||
layout.position_x,
|
||||
layout.position_y,
|
||||
layout.width,
|
||||
layout.height,
|
||||
updatedProperties, // 업데이트된 속성
|
||||
updatedProperties,
|
||||
layout.display_order,
|
||||
layout.layout_type,
|
||||
layout.layout_config,
|
||||
layout.zones_config,
|
||||
newZoneId, // 매핑된 zone_id
|
||||
newZoneId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`);
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
|
||||
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`);
|
||||
// 통계 출력
|
||||
const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length;
|
||||
const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length;
|
||||
const skipCount = screenIds.size - screenDefsToProcess.length;
|
||||
|
||||
logger.info(`
|
||||
✅ 화면 처리 완료:
|
||||
- 신규 복사: ${newCount}개
|
||||
- 업데이트: ${updateCount}개
|
||||
- 스킵 (변경 없음): ${skipCount}개
|
||||
- 총 매핑: ${screenIdMap.size}개
|
||||
`);
|
||||
|
||||
return screenIdMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 변경 여부 확인
|
||||
*/
|
||||
private hasLayoutChanges(
|
||||
sourceLayouts: ScreenLayout[],
|
||||
targetLayouts: ScreenLayout[]
|
||||
): boolean {
|
||||
// 1. 레이아웃 개수가 다르면 변경됨
|
||||
if (sourceLayouts.length !== targetLayouts.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 각 레이아웃의 주요 속성 비교
|
||||
for (let i = 0; i < sourceLayouts.length; i++) {
|
||||
const source = sourceLayouts[i];
|
||||
const target = targetLayouts[i];
|
||||
|
||||
// component_type이 다르면 변경됨
|
||||
if (source.component_type !== target.component_type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 위치/크기가 다르면 변경됨
|
||||
if (
|
||||
source.position_x !== target.position_x ||
|
||||
source.position_y !== target.position_y ||
|
||||
source.width !== target.width ||
|
||||
source.height !== target.height
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// properties의 JSON 문자열 비교 (깊은 비교)
|
||||
const sourceProps = JSON.stringify(source.properties || {});
|
||||
const targetProps = JSON.stringify(target.properties || {});
|
||||
if (sourceProps !== targetProps) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 위상 정렬 (부모 먼저)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -892,6 +892,134 @@ export class ScreenManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
*/
|
||||
async bulkDeleteScreens(
|
||||
screenIds: number[],
|
||||
userCompanyCode: string,
|
||||
deletedBy: string,
|
||||
deleteReason?: string,
|
||||
force: boolean = false
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
skippedCount: number;
|
||||
errors: Array<{ screenId: number; error: string }>;
|
||||
}> {
|
||||
if (screenIds.length === 0) {
|
||||
throw new Error("삭제할 화면을 선택해주세요.");
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const errors: Array<{ screenId: number; error: string }> = [];
|
||||
|
||||
// 각 화면을 개별적으로 삭제 처리
|
||||
for (const screenId of screenIds) {
|
||||
try {
|
||||
// 권한 확인 (Raw Query)
|
||||
const existingResult = await query<{
|
||||
company_code: string | null;
|
||||
is_active: string;
|
||||
screen_name: string;
|
||||
}>(
|
||||
`SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
if (existingResult.length === 0) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "화면을 찾을 수 없습니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingScreen = existingResult[0];
|
||||
|
||||
// 권한 확인
|
||||
if (
|
||||
userCompanyCode !== "*" &&
|
||||
existingScreen.company_code !== userCompanyCode
|
||||
) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "이 화면을 삭제할 권한이 없습니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 삭제된 화면인지 확인
|
||||
if (existingScreen.is_active === "D") {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: "이미 삭제된 화면입니다.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 강제 삭제가 아닌 경우 의존성 체크
|
||||
if (!force) {
|
||||
const dependencyCheck = await this.checkScreenDependencies(
|
||||
screenId,
|
||||
userCompanyCode
|
||||
);
|
||||
if (dependencyCheck.hasDependencies) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
|
||||
await transaction(async (client) => {
|
||||
const now = new Date();
|
||||
|
||||
// 소프트 삭제 (휴지통으로 이동)
|
||||
await client.query(
|
||||
`UPDATE screen_definitions
|
||||
SET is_active = 'D',
|
||||
deleted_date = $1,
|
||||
deleted_by = $2,
|
||||
delete_reason = $3,
|
||||
updated_date = $4,
|
||||
updated_by = $5
|
||||
WHERE screen_id = $6`,
|
||||
[now, deletedBy, deleteReason || null, now, deletedBy, screenId]
|
||||
);
|
||||
|
||||
// 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거)
|
||||
await client.query(
|
||||
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
});
|
||||
|
||||
deletedCount++;
|
||||
logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`);
|
||||
} catch (error) {
|
||||
skippedCount++;
|
||||
errors.push({
|
||||
screenId,
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
logger.error(`화면 삭제 실패: ${screenId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`
|
||||
);
|
||||
|
||||
return { deletedCount, skippedCount, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통 화면 일괄 영구 삭제
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -120,11 +120,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
||||
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
||||
|
||||
// 일괄삭제 관련 상태
|
||||
// 휴지통 일괄삭제 관련 상태
|
||||
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
|
||||
// 활성 화면 일괄삭제 관련 상태
|
||||
const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState<number[]>([]);
|
||||
const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState("");
|
||||
const [activeBulkDeleting, setActiveBulkDeleting] = useState(false);
|
||||
|
||||
// 편집 관련 상태
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
||||
|
|
@ -479,7 +485,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
};
|
||||
|
||||
// 체크박스 선택 처리
|
||||
// 휴지통 체크박스 선택 처리
|
||||
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedScreenIds((prev) => [...prev, screenId]);
|
||||
|
|
@ -488,7 +494,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
// 휴지통 전체 선택/해제
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
||||
|
|
@ -497,7 +503,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
};
|
||||
|
||||
// 일괄삭제 실행
|
||||
// 휴지통 일괄삭제 실행
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedScreenIds.length === 0) {
|
||||
alert("삭제할 화면을 선택해주세요.");
|
||||
|
|
@ -506,6 +512,70 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
setBulkDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 활성 화면 체크박스 선택 처리
|
||||
const handleActiveScreenCheck = (screenId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedActiveScreenIds((prev) => [...prev, screenId]);
|
||||
} else {
|
||||
setSelectedActiveScreenIds((prev) => prev.filter((id) => id !== screenId));
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 화면 전체 선택/해제
|
||||
const handleActiveSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedActiveScreenIds(screens.map((screen) => screen.screenId));
|
||||
} else {
|
||||
setSelectedActiveScreenIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 화면 일괄삭제 실행
|
||||
const handleActiveBulkDelete = () => {
|
||||
if (selectedActiveScreenIds.length === 0) {
|
||||
alert("삭제할 화면을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setActiveBulkDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 활성 화면 일괄삭제 확인
|
||||
const confirmActiveBulkDelete = async () => {
|
||||
if (selectedActiveScreenIds.length === 0) return;
|
||||
|
||||
try {
|
||||
setActiveBulkDeleting(true);
|
||||
const result = await screenApi.bulkDeleteScreens(
|
||||
selectedActiveScreenIds,
|
||||
activeBulkDeleteReason || undefined,
|
||||
true // 강제 삭제 (의존성 무시)
|
||||
);
|
||||
|
||||
// 삭제된 화면들을 목록에서 제거
|
||||
setScreens((prev) => prev.filter((screen) => !selectedActiveScreenIds.includes(screen.screenId)));
|
||||
|
||||
setSelectedActiveScreenIds([]);
|
||||
setActiveBulkDeleteDialogOpen(false);
|
||||
setActiveBulkDeleteReason("");
|
||||
|
||||
// 결과 메시지 표시
|
||||
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
||||
if (result.skippedCount > 0) {
|
||||
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
|
||||
}
|
||||
|
||||
alert(message);
|
||||
} catch (error) {
|
||||
console.error("일괄 삭제 실패:", error);
|
||||
alert("일괄 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setActiveBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = async () => {
|
||||
if (selectedScreenIds.length === 0) return;
|
||||
|
||||
|
|
@ -633,7 +703,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</div>
|
||||
|
||||
{/* 탭 구조 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Tabs value={activeTab} onValueChange={(value) => {
|
||||
setActiveTab(value);
|
||||
// 탭 전환 시 선택 상태 초기화
|
||||
setSelectedActiveScreenIds([]);
|
||||
setSelectedScreenIds([]);
|
||||
}}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
||||
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
||||
|
|
@ -641,11 +716,47 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
|
||||
{/* 활성 화면 탭 */}
|
||||
<TabsContent value="active">
|
||||
{/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */}
|
||||
{selectedActiveScreenIds.length > 0 && (
|
||||
<div className="bg-muted/50 mb-4 flex items-center justify-between rounded-lg border p-3">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedActiveScreenIds.length}개 화면 선택됨
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedActiveScreenIds([])}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
선택 해제
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleActiveBulkDelete}
|
||||
disabled={activeBulkDeleting}
|
||||
className="h-8 gap-1 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{activeBulkDeleting ? "삭제 중..." : "선택 삭제"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||
<div className="bg-card hidden shadow-sm lg:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-12 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
||||
onCheckedChange={handleActiveSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">화면명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">테이블명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
|
|
@ -659,9 +770,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
key={screen.screenId}
|
||||
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
||||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||||
}`}
|
||||
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`}
|
||||
onClick={() => onDesignScreen(screen)}
|
||||
>
|
||||
<TableCell className="h-16 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
||||
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`${screen.screenName} 선택`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
||||
<div>
|
||||
<div className="font-medium">{screen.screenName}</div>
|
||||
|
|
@ -757,17 +876,50 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||
<div className="space-y-4 lg:hidden">
|
||||
{/* 선택 헤더 */}
|
||||
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
||||
onCheckedChange={handleActiveSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">전체 선택</span>
|
||||
</div>
|
||||
{selectedActiveScreenIds.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleActiveBulkDelete}
|
||||
disabled={activeBulkDeleting}
|
||||
className="h-9 gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 삭제`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 목록 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{screens.map((screen) => (
|
||||
<div
|
||||
key={screen.screenId}
|
||||
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
|
||||
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
|
||||
}`}
|
||||
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30 border-primary/50" : ""}`}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
||||
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-1"
|
||||
aria-label={`${screen.screenName} 선택`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
||||
</div>
|
||||
|
|
@ -869,6 +1021,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 휴지통 탭 */}
|
||||
|
|
@ -1225,13 +1378,13 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄삭제 확인 다이얼로그 */}
|
||||
{/* 휴지통 일괄삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-destructive">
|
||||
⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||||
선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||||
<br />
|
||||
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||||
<br />
|
||||
|
|
@ -1254,6 +1407,44 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 활성 화면 일괄삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={activeBulkDeleteDialogOpen} onOpenChange={setActiveBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>선택 화면 삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택된 {selectedActiveScreenIds.length}개 화면을 휴지통으로 이동하시겠습니까?
|
||||
<br />
|
||||
휴지통에서 언제든지 복원할 수 있습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="activeBulkDeleteReason">삭제 사유 (선택사항)</Label>
|
||||
<Textarea
|
||||
id="activeBulkDeleteReason"
|
||||
placeholder="삭제 사유를 입력하세요..."
|
||||
value={activeBulkDeleteReason}
|
||||
onChange={(e) => setActiveBulkDeleteReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setActiveBulkDeleteDialogOpen(false);
|
||||
setActiveBulkDeleteReason("");
|
||||
}}
|
||||
disabled={activeBulkDeleting}
|
||||
>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmActiveBulkDelete} variant="destructive" disabled={activeBulkDeleting}>
|
||||
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 휴지통으로 이동`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 화면 편집 다이얼로그 */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
|
|
|
|||
|
|
@ -112,6 +112,22 @@ export const screenApi = {
|
|||
});
|
||||
},
|
||||
|
||||
// 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||
bulkDeleteScreens: async (
|
||||
screenIds: number[],
|
||||
deleteReason?: string,
|
||||
force?: boolean,
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
skippedCount: number;
|
||||
errors: Array<{ screenId: number; error: string }>;
|
||||
}> => {
|
||||
const response = await apiClient.delete("/screen-management/screens/bulk/delete", {
|
||||
data: { screenIds, deleteReason, force },
|
||||
});
|
||||
return response.data.result;
|
||||
},
|
||||
|
||||
// 휴지통 화면 목록 조회
|
||||
getDeletedScreens: async (params: {
|
||||
page?: number;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback } from "react";
|
|||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -233,14 +234,17 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
return String(data.id || data.objid || data.ID || index);
|
||||
}, []);
|
||||
|
||||
// 카드 선택 핸들러 (테이블 리스트와 동일한 로직)
|
||||
// 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제)
|
||||
const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
|
||||
const newSelectedRows = new Set(selectedRows);
|
||||
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
|
||||
const newSelectedRows = new Set<string>();
|
||||
|
||||
if (checked) {
|
||||
// 선택 시 해당 카드만 선택
|
||||
newSelectedRows.add(cardKey);
|
||||
} else {
|
||||
newSelectedRows.delete(cardKey);
|
||||
}
|
||||
// checked가 false면 빈 Set (선택 해제)
|
||||
|
||||
setSelectedRows(newSelectedRows);
|
||||
|
||||
// 선택된 카드 데이터 계산
|
||||
|
|
@ -265,35 +269,35 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
additionalData: {},
|
||||
}));
|
||||
useModalDataStore.getState().setData(tableNameToUse, modalItems);
|
||||
console.log("✅ [CardDisplay] modalDataStore에 데이터 저장:", {
|
||||
console.log("[CardDisplay] modalDataStore에 데이터 저장:", {
|
||||
dataSourceId: tableNameToUse,
|
||||
count: modalItems.length,
|
||||
});
|
||||
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
||||
useModalDataStore.getState().clearData(tableNameToUse);
|
||||
console.log("🗑️ [CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
|
||||
console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
|
||||
}
|
||||
|
||||
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||
if (splitPanelContext && splitPanelPosition === "left") {
|
||||
if (checked) {
|
||||
splitPanelContext.setSelectedLeftData(data);
|
||||
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
||||
data,
|
||||
parentDataMapping: splitPanelContext.parentDataMapping,
|
||||
});
|
||||
} else if (newSelectedRows.size === 0) {
|
||||
} else {
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 초기화");
|
||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
|
||||
}
|
||||
}
|
||||
}, [selectedRows, displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
||||
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
||||
|
||||
const handleCardClick = useCallback((data: any, index: number) => {
|
||||
const cardKey = getCardKey(data, index);
|
||||
const isCurrentlySelected = selectedRows.has(cardKey);
|
||||
|
||||
// 선택 토글
|
||||
// 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
|
||||
handleCardSelection(cardKey, data, !isCurrentlySelected);
|
||||
|
||||
if (componentConfig.onCardClick) {
|
||||
|
|
@ -396,9 +400,24 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
||||
const getColumnLabel = (columnName: string) => {
|
||||
if (!actualTableColumns || actualTableColumns.length === 0) return columnName;
|
||||
const column = actualTableColumns.find((col) => col.columnName === columnName);
|
||||
return column?.columnLabel || columnName;
|
||||
if (!actualTableColumns || actualTableColumns.length === 0) {
|
||||
// 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환
|
||||
return formatColumnName(columnName);
|
||||
}
|
||||
const column = actualTableColumns.find(
|
||||
(col) => col.columnName === columnName || col.column_name === columnName
|
||||
);
|
||||
// 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨)
|
||||
const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label;
|
||||
return label || formatColumnName(columnName);
|
||||
};
|
||||
|
||||
// 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분)
|
||||
const formatColumnName = (columnName: string) => {
|
||||
// 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화
|
||||
return columnName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
||||
|
|
@ -509,9 +528,25 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
getColumnValue(data, componentConfig.columnMapping?.descriptionColumn) ||
|
||||
getAutoFallbackValue(data, "description");
|
||||
|
||||
const imageValue = componentConfig.columnMapping?.imageColumn
|
||||
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
|
||||
: data.avatar || data.image || "";
|
||||
// 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시
|
||||
const imageColumn = componentConfig.columnMapping?.imageColumn ||
|
||||
Object.keys(data).find(key => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
return lowerKey.includes('image') || lowerKey.includes('photo') ||
|
||||
lowerKey.includes('avatar') || lowerKey.includes('thumbnail') ||
|
||||
lowerKey.includes('picture') || lowerKey.includes('img');
|
||||
});
|
||||
|
||||
// 이미지 값 가져오기 (직접 접근 + 폴백)
|
||||
const imageValue = imageColumn
|
||||
? data[imageColumn]
|
||||
: (data.image_path || data.imagePath || data.avatar || data.image || data.photo || "");
|
||||
|
||||
// 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우
|
||||
const shouldShowImage = componentConfig.cardStyle?.showImage !== false;
|
||||
|
||||
// 이미지 URL 생성 (TableListComponent와 동일한 로직 사용)
|
||||
const imageUrl = imageValue ? getFullImageUrl(imageValue) : "";
|
||||
|
||||
const cardKey = getCardKey(data, index);
|
||||
const isCardSelected = selectedRows.has(cardKey);
|
||||
|
|
@ -526,22 +561,37 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
boxShadow: isCardSelected
|
||||
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
|
||||
: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
||||
flexDirection: "row", // 가로 배치
|
||||
}}
|
||||
className="card-hover group cursor-pointer transition-all duration-150"
|
||||
onClick={() => handleCardClick(data, index)}
|
||||
>
|
||||
{/* 카드 이미지 */}
|
||||
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
||||
<div className="mb-2 flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-lg text-primary">👤</span>
|
||||
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
|
||||
{shouldShowImage && (
|
||||
<div className="flex-shrink-0 flex items-center justify-center mr-4">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={titleValue || "이미지"}
|
||||
className="h-16 w-16 rounded-lg object-cover border border-gray-200"
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 기본 아이콘으로 대체
|
||||
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
|
||||
<span className="text-2xl text-primary">👤</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 타이틀 + 서브타이틀 (가로 배치) */}
|
||||
{/* 우측 컨텐츠 영역 */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{/* 타이틀 + 서브타이틀 */}
|
||||
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
|
||||
<div className="mb-2 flex items-center gap-2 flex-wrap">
|
||||
<div className="mb-1 flex items-center gap-2 flex-wrap">
|
||||
{componentConfig.cardStyle?.showTitle && (
|
||||
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
|
||||
)}
|
||||
|
|
@ -551,26 +601,17 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 설명 */}
|
||||
{componentConfig.cardStyle?.showDescription && (
|
||||
<div className="mb-2 flex-1">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 표시 컬럼들 - 가로 배치 */}
|
||||
{componentConfig.columnMapping?.displayColumns &&
|
||||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border pt-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
||||
const value = getColumnValue(data, columnName);
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{getColumnLabel(columnName)}:</span>
|
||||
<span>{getColumnLabel(columnName)}:</span>
|
||||
<span className="font-medium text-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -578,6 +619,15 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 설명 */}
|
||||
{componentConfig.cardStyle?.showDescription && descriptionValue && (
|
||||
<div className="mt-1 flex-1">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 액션 */}
|
||||
<div className="mt-2 flex justify-end space-x-2">
|
||||
<button
|
||||
|
|
@ -600,6 +650,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1678,3 +1678,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
## 결론
|
||||
|
||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -525,3 +525,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
- ✅ 매핑 엔진 완성
|
||||
|
||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -512,3 +512,4 @@ function ScreenViewPage() {
|
|||
**충돌 위험도: 낮음 (🟢)**
|
||||
|
||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue