화면 일괄삭제기능

This commit is contained in:
kjs 2025-12-03 16:02:09 +09:00
parent 8317af92cd
commit eb5ea411c9
11 changed files with 830 additions and 192 deletions

View File

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

View File

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

View File

@ -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); // 단일 화면 복사 (하위 호환용)

View File

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

View File

@ -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 };
}
/**
*
*/

View File

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

View File

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

View File

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

View File

@ -1678,3 +1678,4 @@ const 출고등록_설정: ScreenSplitPanel = {
## 결론
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.

View File

@ -525,3 +525,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
- ✅ 매핑 엔진 완성
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -512,3 +512,4 @@ function ScreenViewPage() {
**충돌 위험도: 낮음 (🟢)**
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.