Compare commits
No commits in common. "2a72f89c8a7332a91e87cd8304bdf78c2581a22b" and "6982635acd47a759ee9f7271a3da1c27b97800a5" have entirely different histories.
2a72f89c8a
...
6982635acd
|
|
@ -1428,51 +1428,10 @@ 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를 사용한 메뉴 삭제
|
// Raw Query를 사용한 메뉴 삭제
|
||||||
const [deletedMenu] = await query<any>(
|
const [deletedMenu] = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[menuObjid]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||||
|
|
|
||||||
|
|
@ -325,53 +325,6 @@ 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 (
|
export const bulkPermanentDeleteScreens = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
bulkDeleteScreens,
|
|
||||||
checkScreenDependencies,
|
checkScreenDependencies,
|
||||||
restoreScreen,
|
restoreScreen,
|
||||||
permanentDeleteScreen,
|
permanentDeleteScreen,
|
||||||
|
|
@ -45,7 +44,6 @@ router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
|
||||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ interface ScreenDefinition {
|
||||||
layout_metadata: any;
|
layout_metadata: any;
|
||||||
db_source_type: string | null;
|
db_source_type: string | null;
|
||||||
db_connection_id: number | null;
|
db_connection_id: number | null;
|
||||||
source_screen_id: number | null; // 원본 화면 ID (복사 추적용)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -235,27 +234,6 @@ 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;
|
return referenced;
|
||||||
|
|
@ -453,16 +431,14 @@ export class MenuCopyService {
|
||||||
const value = obj[key];
|
const value = obj[key];
|
||||||
const currentPath = path ? `${path}.${key}` : key;
|
const currentPath = path ? `${path}.${key}` : key;
|
||||||
|
|
||||||
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
|
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
|
||||||
if (
|
if (
|
||||||
key === "screen_id" ||
|
key === "screen_id" ||
|
||||||
key === "screenId" ||
|
key === "screenId" ||
|
||||||
key === "targetScreenId" ||
|
key === "targetScreenId"
|
||||||
key === "leftScreenId" ||
|
|
||||||
key === "rightScreenId"
|
|
||||||
) {
|
) {
|
||||||
const numValue = typeof value === "number" ? value : parseInt(value);
|
const numValue = typeof value === "number" ? value : parseInt(value);
|
||||||
if (!isNaN(numValue) && numValue > 0) {
|
if (!isNaN(numValue)) {
|
||||||
const newId = screenIdMap.get(numValue);
|
const newId = screenIdMap.get(numValue);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||||||
|
|
@ -880,10 +856,7 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 복사 (업데이트 또는 신규 생성)
|
* 화면 복사
|
||||||
* - source_screen_id로 기존 복사본 찾기
|
|
||||||
* - 변경된 내용이 있으면 업데이트
|
|
||||||
* - 없으면 새로 복사
|
|
||||||
*/
|
*/
|
||||||
private async copyScreens(
|
private async copyScreens(
|
||||||
screenIds: Set<number>,
|
screenIds: Set<number>,
|
||||||
|
|
@ -903,19 +876,18 @@ export class MenuCopyService {
|
||||||
return screenIdMap;
|
return screenIdMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`);
|
logger.info(`📄 화면 복사 중: ${screenIds.size}개`);
|
||||||
|
|
||||||
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
|
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
|
||||||
const screenDefsToProcess: Array<{
|
const screenDefsToProcess: Array<{
|
||||||
originalScreenId: number;
|
originalScreenId: number;
|
||||||
targetScreenId: number;
|
newScreenId: number;
|
||||||
screenDef: ScreenDefinition;
|
screenDef: ScreenDefinition;
|
||||||
isUpdate: boolean; // 업데이트인지 신규 생성인지
|
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
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 screenDefResult = await client.query<ScreenDefinition>(
|
||||||
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
|
|
@ -928,198 +900,122 @@ export class MenuCopyService {
|
||||||
|
|
||||||
const screenDef = screenDefResult.rows[0];
|
const screenDef = screenDefResult.rows[0];
|
||||||
|
|
||||||
// 2) 기존 복사본 찾기: source_screen_id로 검색
|
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||||
const existingCopyResult = await client.query<{
|
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||||
screen_id: number;
|
`SELECT screen_id FROM screen_definitions
|
||||||
screen_name: string;
|
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||||
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`,
|
LIMIT 1`,
|
||||||
[originalScreenId, targetCompanyCode]
|
[screenDef.screen_code, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3) 화면명 변환 적용
|
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) 화면명 변환 적용
|
||||||
let transformedScreenName = screenDef.screen_name;
|
let transformedScreenName = screenDef.screen_name;
|
||||||
if (screenNameConfig) {
|
if (screenNameConfig) {
|
||||||
|
// 1. 제거할 텍스트 제거
|
||||||
if (screenNameConfig.removeText?.trim()) {
|
if (screenNameConfig.removeText?.trim()) {
|
||||||
transformedScreenName = transformedScreenName.replace(
|
transformedScreenName = transformedScreenName.replace(
|
||||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
transformedScreenName = transformedScreenName.trim();
|
transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 접두사 추가
|
||||||
if (screenNameConfig.addPrefix?.trim()) {
|
if (screenNameConfig.addPrefix?.trim()) {
|
||||||
transformedScreenName =
|
transformedScreenName =
|
||||||
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingCopyResult.rows.length > 0) {
|
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||||
const existingScreen = existingCopyResult.rows[0];
|
`INSERT INTO screen_definitions (
|
||||||
const existingScreenId = existingScreen.screen_id;
|
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)
|
||||||
|
RETURNING screen_id`,
|
||||||
|
[
|
||||||
|
transformedScreenName, // 변환된 화면명
|
||||||
|
newScreenCode, // 새 화면 코드
|
||||||
|
screenDef.table_name,
|
||||||
|
targetCompanyCode, // 새 회사 코드
|
||||||
|
screenDef.description,
|
||||||
|
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
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// 원본 레이아웃 조회
|
const newScreenId = newScreenResult.rows[0].screen_id;
|
||||||
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
screenIdMap.set(originalScreenId, newScreenId);
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
|
||||||
[originalScreenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 대상 레이아웃 조회
|
logger.info(
|
||||||
const targetLayoutsResult = await client.query<ScreenLayout>(
|
` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
);
|
||||||
[existingScreenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
// 저장해서 2단계에서 처리
|
||||||
const hasChanges = this.hasLayoutChanges(
|
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
|
||||||
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, source_screen_id
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
RETURNING screen_id`,
|
|
||||||
[
|
|
||||||
transformedScreenName,
|
|
||||||
newScreenCode,
|
|
||||||
screenDef.table_name,
|
|
||||||
targetCompanyCode,
|
|
||||||
screenDef.description,
|
|
||||||
screenDef.is_active === "D" ? "Y" : screenDef.is_active,
|
|
||||||
screenDef.layout_metadata,
|
|
||||||
screenDef.db_source_type,
|
|
||||||
screenDef.db_connection_id,
|
|
||||||
userId,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
originalScreenId, // source_screen_id 저장
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newScreenId = newScreenResult.rows[0].screen_id;
|
|
||||||
screenIdMap.set(originalScreenId, newScreenId);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})`
|
|
||||||
);
|
|
||||||
|
|
||||||
screenDefsToProcess.push({
|
|
||||||
originalScreenId,
|
|
||||||
targetScreenId: newScreenId,
|
|
||||||
screenDef,
|
|
||||||
isUpdate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ 화면 처리 실패: screen_id=${originalScreenId}`,
|
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
|
||||||
logger.info(
|
logger.info(
|
||||||
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
originalScreenId,
|
originalScreenId,
|
||||||
targetScreenId,
|
newScreenId,
|
||||||
screenDef,
|
screenDef,
|
||||||
isUpdate,
|
|
||||||
} of screenDefsToProcess) {
|
} of screenDefsToProcess) {
|
||||||
try {
|
try {
|
||||||
// 원본 레이아웃 조회
|
// screen_layouts 복사
|
||||||
const layoutsResult = await client.query<ScreenLayout>(
|
const layoutsResult = await client.query<ScreenLayout>(
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||||
[originalScreenId]
|
[originalScreenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isUpdate) {
|
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
|
||||||
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
|
||||||
await client.query(
|
|
||||||
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
|
||||||
[targetScreenId]
|
|
||||||
);
|
|
||||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// component_id 매핑 생성 (원본 → 새 ID)
|
|
||||||
const componentIdMap = new Map<string, string>();
|
const componentIdMap = new Map<string, string>();
|
||||||
for (const layout of layoutsResult.rows) {
|
for (const layout of layoutsResult.rows) {
|
||||||
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
componentIdMap.set(layout.component_id, newComponentId);
|
componentIdMap.set(layout.component_id, newComponentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 삽입
|
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
|
||||||
for (const layout of layoutsResult.rows) {
|
for (const layout of layoutsResult.rows) {
|
||||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||||
|
|
||||||
|
// parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우)
|
||||||
const newParentId = layout.parent_id
|
const newParentId = layout.parent_id
|
||||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -1127,6 +1023,7 @@ export class MenuCopyService {
|
||||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// properties 내부 참조 업데이트
|
||||||
const updatedProperties = this.updateReferencesInProperties(
|
const updatedProperties = this.updateReferencesInProperties(
|
||||||
layout.properties,
|
layout.properties,
|
||||||
screenIdMap,
|
screenIdMap,
|
||||||
|
|
@ -1140,94 +1037,38 @@ export class MenuCopyService {
|
||||||
display_order, layout_type, layout_config, zones_config, zone_id
|
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)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||||
[
|
[
|
||||||
targetScreenId,
|
newScreenId, // 새 화면 ID
|
||||||
layout.component_type,
|
layout.component_type,
|
||||||
newComponentId,
|
newComponentId, // 새 컴포넌트 ID
|
||||||
newParentId,
|
newParentId, // 매핑된 parent_id
|
||||||
layout.position_x,
|
layout.position_x,
|
||||||
layout.position_y,
|
layout.position_y,
|
||||||
layout.width,
|
layout.width,
|
||||||
layout.height,
|
layout.height,
|
||||||
updatedProperties,
|
updatedProperties, // 업데이트된 속성
|
||||||
layout.display_order,
|
layout.display_order,
|
||||||
layout.layout_type,
|
layout.layout_type,
|
||||||
layout.layout_config,
|
layout.layout_config,
|
||||||
layout.zones_config,
|
layout.zones_config,
|
||||||
newZoneId,
|
newZoneId, // 매핑된 zone_id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = isUpdate ? "업데이트" : "복사";
|
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`);
|
||||||
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw 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;
|
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,134 +892,6 @@ 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴지통 화면 일괄 영구 삭제
|
* 휴지통 화면 일괄 영구 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1645,23 +1517,11 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
|
|
||||||
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
|
|
||||||
|
|
||||||
const components: ComponentData[] = componentLayouts.map((layout) => {
|
const components: ComponentData[] = componentLayouts.map((layout) => {
|
||||||
const properties = layout.properties as any;
|
const properties = layout.properties as any;
|
||||||
|
|
||||||
// 🔥 최신 inputType으로 widgetType 및 componentType 업데이트
|
|
||||||
const tableName = properties?.tableName;
|
|
||||||
const columnName = properties?.columnName;
|
|
||||||
const latestTypeInfo = tableName && columnName
|
|
||||||
? inputTypeMap.get(`${tableName}.${columnName}`)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const component = {
|
const component = {
|
||||||
id: layout.component_id,
|
id: layout.component_id,
|
||||||
// 🔥 최신 componentType이 있으면 type 덮어쓰기
|
type: layout.component_type as any,
|
||||||
type: latestTypeInfo?.componentType || layout.component_type as any,
|
|
||||||
position: {
|
position: {
|
||||||
x: layout.position_x,
|
x: layout.position_x,
|
||||||
y: layout.position_y,
|
y: layout.position_y,
|
||||||
|
|
@ -1670,17 +1530,6 @@ export class ScreenManagementService {
|
||||||
size: { width: layout.width, height: layout.height },
|
size: { width: layout.width, height: layout.height },
|
||||||
parentId: layout.parent_id,
|
parentId: layout.parent_id,
|
||||||
...properties,
|
...properties,
|
||||||
// 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기
|
|
||||||
...(latestTypeInfo && {
|
|
||||||
widgetType: latestTypeInfo.inputType,
|
|
||||||
inputType: latestTypeInfo.inputType,
|
|
||||||
componentType: latestTypeInfo.componentType,
|
|
||||||
componentConfig: {
|
|
||||||
...properties?.componentConfig,
|
|
||||||
type: latestTypeInfo.componentType,
|
|
||||||
inputType: latestTypeInfo.inputType,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`로드된 컴포넌트:`, {
|
console.log(`로드된 컴포넌트:`, {
|
||||||
|
|
@ -1690,9 +1539,6 @@ export class ScreenManagementService {
|
||||||
size: component.size,
|
size: component.size,
|
||||||
parentId: component.parentId,
|
parentId: component.parentId,
|
||||||
title: (component as any).title,
|
title: (component as any).title,
|
||||||
widgetType: (component as any).widgetType,
|
|
||||||
componentType: (component as any).componentType,
|
|
||||||
latestTypeInfo,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
|
|
@ -1712,112 +1558,6 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
|
||||||
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
|
||||||
*/
|
|
||||||
private getComponentIdFromInputType(inputType: string): string {
|
|
||||||
const mapping: Record<string, string> = {
|
|
||||||
// 텍스트 입력
|
|
||||||
text: "text-input",
|
|
||||||
email: "text-input",
|
|
||||||
password: "text-input",
|
|
||||||
tel: "text-input",
|
|
||||||
// 숫자 입력
|
|
||||||
number: "number-input",
|
|
||||||
decimal: "number-input",
|
|
||||||
// 날짜/시간
|
|
||||||
date: "date-input",
|
|
||||||
datetime: "date-input",
|
|
||||||
time: "date-input",
|
|
||||||
// 텍스트 영역
|
|
||||||
textarea: "textarea-basic",
|
|
||||||
// 선택
|
|
||||||
select: "select-basic",
|
|
||||||
dropdown: "select-basic",
|
|
||||||
// 체크박스/라디오
|
|
||||||
checkbox: "checkbox-basic",
|
|
||||||
radio: "radio-basic",
|
|
||||||
boolean: "toggle-switch",
|
|
||||||
// 파일
|
|
||||||
file: "file-upload",
|
|
||||||
// 이미지
|
|
||||||
image: "image-widget",
|
|
||||||
img: "image-widget",
|
|
||||||
picture: "image-widget",
|
|
||||||
photo: "image-widget",
|
|
||||||
// 버튼
|
|
||||||
button: "button-primary",
|
|
||||||
// 기타
|
|
||||||
label: "text-display",
|
|
||||||
code: "select-basic",
|
|
||||||
entity: "select-basic",
|
|
||||||
category: "select-basic",
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapping[inputType] || "text-input";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트들의 최신 inputType 정보 조회
|
|
||||||
* @param layouts - 레이아웃 목록
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
* @returns Map<"tableName.columnName", { inputType, componentType }>
|
|
||||||
*/
|
|
||||||
private async getLatestInputTypes(
|
|
||||||
layouts: any[],
|
|
||||||
companyCode: string
|
|
||||||
): Promise<Map<string, { inputType: string; componentType: string }>> {
|
|
||||||
const inputTypeMap = new Map<string, { inputType: string; componentType: string }>();
|
|
||||||
|
|
||||||
// tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출
|
|
||||||
const tableColumnPairs = new Set<string>();
|
|
||||||
for (const layout of layouts) {
|
|
||||||
const properties = layout.properties as any;
|
|
||||||
if (properties?.tableName && properties?.columnName) {
|
|
||||||
tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tableColumnPairs.size === 0) {
|
|
||||||
return inputTypeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 테이블-컬럼 조합에 대해 최신 inputType 조회
|
|
||||||
const pairs = Array.from(tableColumnPairs).map(pair => {
|
|
||||||
const [tableName, columnName] = pair.split('|');
|
|
||||||
return { tableName, columnName };
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배치 쿼리로 한 번에 조회
|
|
||||||
const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ');
|
|
||||||
const params = pairs.flatMap(p => [p.tableName, p.columnName]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await query<{ table_name: string; column_name: string; input_type: string }>(
|
|
||||||
`SELECT table_name, column_name, input_type
|
|
||||||
FROM table_type_columns
|
|
||||||
WHERE (table_name, column_name) IN (${placeholders})
|
|
||||||
AND company_code = $${params.length + 1}`,
|
|
||||||
[...params, companyCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of results) {
|
|
||||||
const componentType = this.getComponentIdFromInputType(row.input_type);
|
|
||||||
inputTypeMap.set(`${row.table_name}.${row.column_name}`, {
|
|
||||||
inputType: row.input_type,
|
|
||||||
componentType: componentType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`최신 inputType 조회 완료: ${results.length}개`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`최신 inputType 조회 실패 (무시됨):`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputTypeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 템플릿 관리
|
// 템플릿 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -797,9 +797,6 @@ export class TableManagementService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
|
|
||||||
await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode);
|
|
||||||
|
|
||||||
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
||||||
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
||||||
cache.delete(cacheKeyPattern);
|
cache.delete(cacheKeyPattern);
|
||||||
|
|
@ -819,135 +816,6 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 입력 타입에 해당하는 컴포넌트 ID 반환
|
|
||||||
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
|
|
||||||
*/
|
|
||||||
private getComponentIdFromInputType(inputType: string): string {
|
|
||||||
const mapping: Record<string, string> = {
|
|
||||||
// 텍스트 입력
|
|
||||||
text: "text-input",
|
|
||||||
email: "text-input",
|
|
||||||
password: "text-input",
|
|
||||||
tel: "text-input",
|
|
||||||
// 숫자 입력
|
|
||||||
number: "number-input",
|
|
||||||
decimal: "number-input",
|
|
||||||
// 날짜/시간
|
|
||||||
date: "date-input",
|
|
||||||
datetime: "date-input",
|
|
||||||
time: "date-input",
|
|
||||||
// 텍스트 영역
|
|
||||||
textarea: "textarea-basic",
|
|
||||||
// 선택
|
|
||||||
select: "select-basic",
|
|
||||||
dropdown: "select-basic",
|
|
||||||
// 체크박스/라디오
|
|
||||||
checkbox: "checkbox-basic",
|
|
||||||
radio: "radio-basic",
|
|
||||||
boolean: "toggle-switch",
|
|
||||||
// 파일
|
|
||||||
file: "file-upload",
|
|
||||||
// 이미지
|
|
||||||
image: "image-widget",
|
|
||||||
img: "image-widget",
|
|
||||||
picture: "image-widget",
|
|
||||||
photo: "image-widget",
|
|
||||||
// 버튼
|
|
||||||
button: "button-primary",
|
|
||||||
// 기타
|
|
||||||
label: "text-display",
|
|
||||||
code: "select-basic",
|
|
||||||
entity: "select-basic",
|
|
||||||
category: "select-basic",
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapping[inputType] || "text-input";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화
|
|
||||||
* @param tableName - 테이블명
|
|
||||||
* @param columnName - 컬럼명
|
|
||||||
* @param inputType - 새로운 입력 타입
|
|
||||||
* @param companyCode - 회사 코드
|
|
||||||
*/
|
|
||||||
private async syncScreenLayoutsInputType(
|
|
||||||
tableName: string,
|
|
||||||
columnName: string,
|
|
||||||
inputType: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 해당 컬럼을 사용하는 화면 레이아웃 조회
|
|
||||||
const affectedLayouts = await query<{
|
|
||||||
layout_id: number;
|
|
||||||
screen_id: number;
|
|
||||||
component_id: string;
|
|
||||||
component_type: string;
|
|
||||||
properties: any;
|
|
||||||
}>(
|
|
||||||
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
|
|
||||||
FROM screen_layouts sl
|
|
||||||
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
|
||||||
WHERE sl.properties->>'tableName' = $1
|
|
||||||
AND sl.properties->>'columnName' = $2
|
|
||||||
AND (sd.company_code = $3 OR $3 = '*')`,
|
|
||||||
[tableName, columnName, companyCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (affectedLayouts.length === 0) {
|
|
||||||
logger.info(
|
|
||||||
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 새로운 componentType 계산
|
|
||||||
const newComponentType = this.getComponentIdFromInputType(inputType);
|
|
||||||
|
|
||||||
// 각 레이아웃의 widgetType, componentType 업데이트
|
|
||||||
for (const layout of affectedLayouts) {
|
|
||||||
const updatedProperties = {
|
|
||||||
...layout.properties,
|
|
||||||
widgetType: inputType,
|
|
||||||
inputType: inputType,
|
|
||||||
// componentConfig 내부의 type도 업데이트
|
|
||||||
componentConfig: {
|
|
||||||
...layout.properties?.componentConfig,
|
|
||||||
type: newComponentType,
|
|
||||||
inputType: inputType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await query(
|
|
||||||
`UPDATE screen_layouts
|
|
||||||
SET properties = $1, component_type = $2
|
|
||||||
WHERE layout_id = $3`,
|
|
||||||
[JSON.stringify(updatedProperties), newComponentType, layout.layout_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
|
|
||||||
logger.warn(
|
|
||||||
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입력 타입별 기본 상세 설정 생성
|
* 입력 타입별 기본 상세 설정 생성
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1093,283 +1093,229 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
||||||
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col space-y-4 overflow-hidden">
|
||||||
{!selectedTable ? (
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
{!selectedTable ? (
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
||||||
<p className="text-muted-foreground text-sm">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
<p className="text-muted-foreground text-sm">
|
||||||
</p>
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
{/* 테이블 라벨 설정 */}
|
||||||
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */}
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<div className="mb-4 flex items-center gap-4">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<Input
|
||||||
<Input
|
value={tableLabel}
|
||||||
value={tableLabel}
|
onChange={(e) => setTableLabel(e.target.value)}
|
||||||
onChange={(e) => setTableLabel(e.target.value)}
|
placeholder="테이블 표시명"
|
||||||
placeholder="테이블 표시명"
|
className="h-10 text-sm"
|
||||||
className="h-10 text-sm"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={tableDescription}
|
|
||||||
onChange={(e) => setTableDescription(e.target.value)}
|
|
||||||
placeholder="테이블 설명"
|
|
||||||
className="h-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
|
|
||||||
<Button
|
|
||||||
onClick={saveAllSettings}
|
|
||||||
disabled={!selectedTable || columns.length === 0}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
전체 설정 저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{columnsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<LoadingSpinner />
|
|
||||||
<span className="text-muted-foreground ml-2 text-sm">
|
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : columns.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
{/* 컬럼 헤더 (고정) */}
|
|
||||||
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
|
||||||
<div className="pr-4">컬럼명</div>
|
|
||||||
<div className="px-4">라벨</div>
|
|
||||||
<div className="pr-6">입력 타입</div>
|
|
||||||
<div className="pl-4">설명</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={tableDescription}
|
||||||
|
onChange={(e) => setTableDescription(e.target.value)}
|
||||||
|
placeholder="테이블 설명"
|
||||||
|
className="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 리스트 (스크롤 영역) */}
|
{columnsLoading ? (
|
||||||
<div
|
<div className="flex items-center justify-center py-8">
|
||||||
className="flex-1 overflow-y-auto"
|
<LoadingSpinner />
|
||||||
onScroll={(e) => {
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
</span>
|
||||||
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
</div>
|
||||||
loadMoreColumns();
|
) : columns.length === 0 ? (
|
||||||
}
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
}}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
>
|
</div>
|
||||||
{columns.map((column, index) => (
|
) : (
|
||||||
<div
|
<div className="space-y-4">
|
||||||
key={column.columnName}
|
{/* 컬럼 헤더 */}
|
||||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
<div className="text-foreground grid h-12 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
||||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
<div className="pr-4">컬럼명</div>
|
||||||
>
|
<div className="px-4">라벨</div>
|
||||||
<div className="pr-4 pt-1">
|
<div className="pr-6">입력 타입</div>
|
||||||
<div className="font-mono text-sm">{column.columnName}</div>
|
<div className="pl-4">설명</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
|
||||||
<Input
|
{/* 컬럼 리스트 */}
|
||||||
value={column.displayName || ""}
|
<div
|
||||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
className="max-h-96 overflow-y-auto"
|
||||||
placeholder={column.columnName}
|
onScroll={(e) => {
|
||||||
className="h-8 text-xs"
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
/>
|
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||||
</div>
|
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
||||||
<div className="pr-6">
|
loadMoreColumns();
|
||||||
<div className="space-y-3">
|
}
|
||||||
{/* 입력 타입 선택 */}
|
}}
|
||||||
<Select
|
>
|
||||||
value={column.inputType || "text"}
|
{columns.map((column, index) => (
|
||||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
<div
|
||||||
>
|
key={column.columnName}
|
||||||
<SelectTrigger className="h-8 text-xs">
|
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||||
<SelectValue placeholder="입력 타입 선택" />
|
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<div className="pr-4 pt-1">
|
||||||
{memoizedInputTypeOptions.map((option) => (
|
<div className="font-mono text-sm">{column.columnName}</div>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
</div>
|
||||||
{option.label}
|
<div className="px-4">
|
||||||
</SelectItem>
|
<Input
|
||||||
))}
|
value={column.displayName || ""}
|
||||||
</SelectContent>
|
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||||
</Select>
|
placeholder={column.columnName}
|
||||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
className="h-8 text-xs"
|
||||||
{column.inputType === "code" && (
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pr-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 입력 타입 선택 */}
|
||||||
<Select
|
<Select
|
||||||
value={column.codeCategory || "none"}
|
value={column.inputType || "text"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||||
handleDetailSettingsChange(column.columnName, "code", value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="공통코드 선택" />
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{commonCodeOptions.map((option, index) => (
|
{memoizedInputTypeOptions.map((option) => (
|
||||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
{column.inputType === "code" && (
|
||||||
{column.inputType === "category" && (
|
<Select
|
||||||
<div className="space-y-2">
|
value={column.codeCategory || "none"}
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
onValueChange={(value) =>
|
||||||
적용할 메뉴 (2레벨)
|
handleDetailSettingsChange(column.columnName, "code", value)
|
||||||
</label>
|
}
|
||||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
>
|
||||||
{secondLevelMenus.length === 0 ? (
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<p className="text-xs text-muted-foreground">
|
<SelectValue placeholder="공통코드 선택" />
|
||||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
</SelectTrigger>
|
||||||
</p>
|
<SelectContent>
|
||||||
) : (
|
{commonCodeOptions.map((option, index) => (
|
||||||
secondLevelMenus.map((menu) => {
|
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||||
// menuObjid를 숫자로 변환하여 비교
|
{option.label}
|
||||||
const menuObjidNum = Number(menu.menuObjid);
|
</SelectItem>
|
||||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
))}
|
||||||
|
</SelectContent>
|
||||||
return (
|
</Select>
|
||||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
)}
|
||||||
<input
|
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||||
type="checkbox"
|
{column.inputType === "category" && (
|
||||||
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
<div className="space-y-2">
|
||||||
checked={isChecked}
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
onChange={(e) => {
|
적용할 메뉴 (2레벨)
|
||||||
const currentMenus = column.categoryMenus || [];
|
</label>
|
||||||
const newMenus = e.target.checked
|
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||||
? [...currentMenus, menuObjidNum]
|
{secondLevelMenus.length === 0 ? (
|
||||||
: currentMenus.filter((id) => id !== menuObjidNum);
|
<p className="text-xs text-muted-foreground">
|
||||||
|
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
secondLevelMenus.map((menu) => {
|
||||||
|
// menuObjid를 숫자로 변환하여 비교
|
||||||
|
const menuObjidNum = Number(menu.menuObjid);
|
||||||
|
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentMenus = column.categoryMenus || [];
|
||||||
|
const newMenus = e.target.checked
|
||||||
|
? [...currentMenus, menuObjidNum]
|
||||||
|
: currentMenus.filter((id) => id !== menuObjidNum);
|
||||||
|
|
||||||
setColumns((prev) =>
|
setColumns((prev) =>
|
||||||
prev.map((col) =>
|
prev.map((col) =>
|
||||||
col.columnName === column.columnName
|
col.columnName === column.columnName
|
||||||
? { ...col, categoryMenus: newMenus }
|
? { ...col, categoryMenus: newMenus }
|
||||||
: col
|
: col
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||||
className="text-xs cursor-pointer flex-1"
|
className="text-xs cursor-pointer flex-1"
|
||||||
>
|
>
|
||||||
{menu.parentMenuName} → {menu.menuName}
|
{menu.parentMenuName} → {menu.menuName}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||||
|
<p className="text-primary text-xs">
|
||||||
|
{column.categoryMenus.length}개 메뉴 선택됨
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
)}
|
||||||
<p className="text-primary text-xs">
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
{column.categoryMenus.length}개 메뉴 선택됨
|
{column.inputType === "entity" && (
|
||||||
</p>
|
<>
|
||||||
)}
|
{/* 참조 테이블 */}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
|
||||||
{column.inputType === "entity" && (
|
|
||||||
<>
|
|
||||||
{/* 참조 테이블 */}
|
|
||||||
<div className="w-48">
|
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
|
||||||
참조 테이블
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={column.referenceTable || "none"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
|
||||||
<SelectValue placeholder="선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{referenceTableOptions.map((option, index) => (
|
|
||||||
<SelectItem
|
|
||||||
key={`entity-${option.value}-${index}`}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{option.label}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{option.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 조인 컬럼 */}
|
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
조인 컬럼
|
참조 테이블
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceColumn || "none"}
|
value={column.referenceTable || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(
|
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||||
column.columnName,
|
|
||||||
"entity_reference_column",
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
{referenceTableOptions.map((option, index) => (
|
||||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={`ref-col-${refCol.columnName}-${index}`}
|
key={`entity-${option.value}-${index}`}
|
||||||
value={refCol.columnName}
|
value={option.value}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
<div className="flex flex-col">
|
||||||
</SelectItem>
|
<span className="font-medium">{option.label}</span>
|
||||||
))}
|
<span className="text-muted-foreground text-xs">
|
||||||
{(!referenceTableColumns[column.referenceTable] ||
|
{option.value}
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
</span>
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
|
||||||
로딩중
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 표시 컬럼 */}
|
{/* 조인 컬럼 */}
|
||||||
{column.referenceTable &&
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
column.referenceTable !== "none" &&
|
|
||||||
column.referenceColumn &&
|
|
||||||
column.referenceColumn !== "none" && (
|
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
표시 컬럼
|
조인 컬럼
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.displayColumn || "none"}
|
value={column.referenceColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(
|
handleDetailSettingsChange(
|
||||||
column.columnName,
|
column.columnName,
|
||||||
"entity_display_column",
|
"entity_reference_column",
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1401,32 +1347,79 @@ export default function TableManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 설정 완료 표시 */}
|
{/* 표시 컬럼 */}
|
||||||
{column.referenceTable &&
|
{column.referenceTable &&
|
||||||
column.referenceTable !== "none" &&
|
column.referenceTable !== "none" &&
|
||||||
column.referenceColumn &&
|
column.referenceColumn &&
|
||||||
column.referenceColumn !== "none" &&
|
column.referenceColumn !== "none" && (
|
||||||
column.displayColumn &&
|
<div className="w-48">
|
||||||
column.displayColumn !== "none" && (
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
표시 컬럼
|
||||||
<span>✓</span>
|
</label>
|
||||||
<span className="truncate">설정 완료</span>
|
<Select
|
||||||
</div>
|
value={column.displayColumn || "none"}
|
||||||
)}
|
onValueChange={(value) =>
|
||||||
</>
|
handleDetailSettingsChange(
|
||||||
)}
|
column.columnName,
|
||||||
|
"entity_display_column",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||||
|
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`ref-col-${refCol.columnName}-${index}`}
|
||||||
|
value={refCol.columnName}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!referenceTableColumns[column.referenceTable] ||
|
||||||
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
|
로딩중
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정 완료 표시 */}
|
||||||
|
{column.referenceTable &&
|
||||||
|
column.referenceTable !== "none" &&
|
||||||
|
column.referenceColumn &&
|
||||||
|
column.referenceColumn !== "none" &&
|
||||||
|
column.displayColumn &&
|
||||||
|
column.displayColumn !== "none" && (
|
||||||
|
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||||
|
<span>✓</span>
|
||||||
|
<span className="truncate">설정 완료</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-4">
|
||||||
|
<Input
|
||||||
|
value={column.description || ""}
|
||||||
|
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||||
|
placeholder="설명"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-4">
|
))}
|
||||||
<Input
|
</div>
|
||||||
value={column.description || ""}
|
|
||||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
|
||||||
placeholder="설명"
|
|
||||||
className="h-8 w-full text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 로딩 표시 */}
|
{/* 로딩 표시 */}
|
||||||
{columnsLoading && (
|
{columnsLoading && (
|
||||||
|
|
@ -1435,16 +1428,28 @@ export default function TableManagementPage() {
|
||||||
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 페이지 정보 (고정 하단) */}
|
{/* 페이지 정보 */}
|
||||||
<div className="text-muted-foreground flex-shrink-0 border-t py-2 text-center text-sm">
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 저장 버튼 */}
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={saveAllSettings}
|
||||||
|
disabled={!selectedTable || columns.length === 0}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
전체 설정 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ function ScreenViewPage() {
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="bg-background flex h-full w-full items-center justify-center overflow-auto"
|
className="bg-background flex h-full w-full items-center justify-center overflow-auto pt-8"
|
||||||
>
|
>
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
LogOut,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
@ -24,17 +22,8 @@ import { useProfile } from "@/hooks/useProfile";
|
||||||
import { MenuItem } from "@/lib/api/menu";
|
import { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { MainHeader } from "./MainHeader";
|
||||||
import { ProfileModal } from "./ProfileModal";
|
import { ProfileModal } from "./ProfileModal";
|
||||||
import { Logo } from "./Logo";
|
|
||||||
import { SideMenu } from "./SideMenu";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
// useAuth의 UserInfo 타입을 확장
|
// useAuth의 UserInfo 타입을 확장
|
||||||
interface ExtendedUserInfo {
|
interface ExtendedUserInfo {
|
||||||
|
|
@ -408,152 +397,82 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-white">
|
<div className="flex h-screen flex-col bg-white">
|
||||||
{/* 모바일 사이드바 오버레이 */}
|
{/* MainHeader 컴포넌트 사용 */}
|
||||||
{sidebarOpen && isMobile && (
|
<MainHeader
|
||||||
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
user={user}
|
||||||
)}
|
onSidebarToggle={() => {
|
||||||
|
// 모바일에서만 토글 동작
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(!sidebarOpen);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onProfileClick={openProfileModal}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 왼쪽 사이드바 */}
|
<div className="flex flex-1 pt-14">
|
||||||
<aside
|
{/* 모바일 사이드바 오버레이 */}
|
||||||
className={`${
|
{sidebarOpen && isMobile && (
|
||||||
isMobile
|
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-0 left-0 z-40"
|
|
||||||
: "relative z-auto translate-x-0"
|
|
||||||
} flex h-screen w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
|
||||||
>
|
|
||||||
{/* 사이드바 최상단 - 로고 + 모바일 햄버거 메뉴 */}
|
|
||||||
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-4">
|
|
||||||
<Logo />
|
|
||||||
{/* 모바일 햄버거 메뉴 버튼 */}
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<SideMenu onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
|
||||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
|
||||||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
|
||||||
(user as ExtendedUserInfo)?.userType === "admin") && (
|
|
||||||
<div className="border-b border-slate-200 p-3">
|
|
||||||
<Button
|
|
||||||
onClick={handleModeSwitch}
|
|
||||||
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
|
||||||
isAdminMode
|
|
||||||
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
|
||||||
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isAdminMode ? (
|
|
||||||
<>
|
|
||||||
<UserCheck className="h-4 w-4" />
|
|
||||||
사용자 메뉴로 전환
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
관리자 메뉴로 전환
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 메뉴 영역 */}
|
{/* 왼쪽 사이드바 */}
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
<aside
|
||||||
<nav className="space-y-1 px-3">
|
className={`${
|
||||||
{loading ? (
|
isMobile
|
||||||
<div className="animate-pulse space-y-2">
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
||||||
{[...Array(5)].map((_, i) => (
|
: "relative top-0 z-auto translate-x-0"
|
||||||
<div key={i} className="h-8 rounded bg-slate-200"></div>
|
} flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
))}
|
>
|
||||||
</div>
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
) : (
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||||
uiMenus.map((menu) => renderMenu(menu))
|
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
||||||
)}
|
(user as ExtendedUserInfo)?.userType === "admin") && (
|
||||||
</nav>
|
<div className="border-b border-slate-200 p-3">
|
||||||
</div>
|
<Button
|
||||||
|
onClick={handleModeSwitch}
|
||||||
|
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
||||||
|
isAdminMode
|
||||||
|
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
||||||
|
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isAdminMode ? (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
사용자 메뉴로 전환
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
관리자 메뉴로 전환
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 사이드바 하단 - 사용자 프로필 */}
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
<div className="border-t border-slate-200 p-3">
|
<nav className="space-y-1 px-3">
|
||||||
<DropdownMenu modal={false}>
|
{loading ? (
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="animate-pulse space-y-2">
|
||||||
<button className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-slate-100">
|
{[...Array(5)].map((_, i) => (
|
||||||
{/* 프로필 아바타 */}
|
<div key={i} className="h-8 rounded bg-slate-200"></div>
|
||||||
<div className="relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full">
|
))}
|
||||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
|
||||||
<img
|
|
||||||
src={user.photo}
|
|
||||||
alt={user.userName || "User"}
|
|
||||||
className="aspect-square h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700">
|
|
||||||
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* 사용자 정보 */}
|
) : (
|
||||||
<div className="min-w-0 flex-1">
|
uiMenus.map((menu) => renderMenu(menu))
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
)}
|
||||||
{user.userName || "사용자"}
|
</nav>
|
||||||
</p>
|
</div>
|
||||||
<p className="truncate text-xs text-slate-500">
|
</aside>
|
||||||
{user.deptName || user.email || user.userId}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{/* 프로필 사진 표시 */}
|
|
||||||
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
|
||||||
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
|
||||||
<img
|
|
||||||
src={user.photo}
|
|
||||||
alt={user.userName || "User"}
|
|
||||||
className="aspect-square h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-base font-semibold text-slate-700">
|
|
||||||
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 사용자 정보 */}
|
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||||
<div className="flex flex-col space-y-1">
|
<main className="h-[calc(100vh-3.5rem)] min-w-0 flex-1 overflow-auto bg-white">
|
||||||
<p className="text-sm leading-none font-medium">
|
{children}
|
||||||
{user.userName || "사용자"} ({user.userId || ""})
|
</main>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs leading-none font-semibold">{user.email || ""}</p>
|
|
||||||
<p className="text-muted-foreground text-xs leading-none font-semibold">
|
|
||||||
{user.deptName && user.positionName
|
|
||||||
? `${user.deptName}, ${user.positionName}`
|
|
||||||
: user.deptName || user.positionName || "부서 정보 없음"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={openProfileModal}>
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>프로필</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>로그아웃</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
|
||||||
<main className="h-screen min-w-0 flex-1 overflow-auto bg-white">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* 프로필 수정 모달 */}
|
{/* 프로필 수정 모달 */}
|
||||||
<ProfileModal
|
<ProfileModal
|
||||||
|
|
|
||||||
|
|
@ -1265,7 +1265,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[layout, screenResolution, saveToHistory],
|
[layout, screenResolution, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 해상도 변경 핸들러 (컴포넌트 크기/위치 유지)
|
// 해상도 변경 핸들러 (자동 스케일링 포함)
|
||||||
const handleResolutionChange = useCallback(
|
const handleResolutionChange = useCallback(
|
||||||
(newResolution: ScreenResolution) => {
|
(newResolution: ScreenResolution) => {
|
||||||
const oldWidth = screenResolution.width;
|
const oldWidth = screenResolution.width;
|
||||||
|
|
@ -1273,28 +1273,122 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const newWidth = newResolution.width;
|
const newWidth = newResolution.width;
|
||||||
const newHeight = newResolution.height;
|
const newHeight = newResolution.height;
|
||||||
|
|
||||||
console.log("📱 해상도 변경:", {
|
console.log("📱 해상도 변경 시작:", {
|
||||||
from: `${oldWidth}x${oldHeight}`,
|
from: `${oldWidth}x${oldHeight}`,
|
||||||
to: `${newWidth}x${newHeight}`,
|
to: `${newWidth}x${newHeight}`,
|
||||||
componentsCount: layout.components.length,
|
hasComponents: layout.components.length > 0,
|
||||||
|
snapToGrid: layout.gridSettings?.snapToGrid || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setScreenResolution(newResolution);
|
setScreenResolution(newResolution);
|
||||||
|
|
||||||
// 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지
|
// 컴포넌트가 없으면 해상도만 변경
|
||||||
|
if (layout.components.length === 0) {
|
||||||
|
const updatedLayout = {
|
||||||
|
...layout,
|
||||||
|
screenResolution: newResolution,
|
||||||
|
};
|
||||||
|
setLayout(updatedLayout);
|
||||||
|
saveToHistory(updatedLayout);
|
||||||
|
console.log("✅ 해상도 변경 완료 (컴포넌트 없음)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비율 계산
|
||||||
|
const scaleX = newWidth / oldWidth;
|
||||||
|
const scaleY = newHeight / oldHeight;
|
||||||
|
|
||||||
|
console.log("📐 스케일링 비율:", {
|
||||||
|
scaleX: `${(scaleX * 100).toFixed(2)}%`,
|
||||||
|
scaleY: `${(scaleY * 100).toFixed(2)}%`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트 재귀적으로 스케일링하는 함수
|
||||||
|
const scaleComponent = (comp: ComponentData): ComponentData => {
|
||||||
|
// 위치 스케일링
|
||||||
|
const scaledPosition = {
|
||||||
|
x: comp.position.x * scaleX,
|
||||||
|
y: comp.position.y * scaleY,
|
||||||
|
z: comp.position.z || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 크기 스케일링
|
||||||
|
const scaledSize = {
|
||||||
|
width: comp.size.width * scaleX,
|
||||||
|
height: comp.size.height * scaleY,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
position: scaledPosition,
|
||||||
|
size: scaledSize,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨)
|
||||||
|
const scaledComponents = layout.components.map(scaleComponent);
|
||||||
|
|
||||||
|
console.log("🔄 컴포넌트 스케일링 완료:", {
|
||||||
|
totalComponents: scaledComponents.length,
|
||||||
|
groupComponents: scaledComponents.filter((c) => c.type === "group").length,
|
||||||
|
note: "그룹의 자식 컴포넌트도 모두 스케일링됨",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 격자 스냅이 활성화된 경우 격자에 맞춰 재조정
|
||||||
|
let finalComponents = scaledComponents;
|
||||||
|
if (layout.gridSettings?.snapToGrid) {
|
||||||
|
const newGridInfo = calculateGridInfo(newWidth, newHeight, {
|
||||||
|
columns: layout.gridSettings.columns,
|
||||||
|
gap: layout.gridSettings.gap,
|
||||||
|
padding: layout.gridSettings.padding,
|
||||||
|
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridUtilSettings = {
|
||||||
|
columns: layout.gridSettings.columns,
|
||||||
|
gap: layout.gridSettings.gap,
|
||||||
|
padding: layout.gridSettings.padding,
|
||||||
|
snapToGrid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
finalComponents = scaledComponents.map((comp) => {
|
||||||
|
const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings);
|
||||||
|
const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings);
|
||||||
|
|
||||||
|
// gridColumns 재계산
|
||||||
|
const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
position: snappedPosition,
|
||||||
|
size: snappedSize,
|
||||||
|
gridColumns: adjustedGridColumns,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🧲 격자 스냅 적용 완료");
|
||||||
|
}
|
||||||
|
|
||||||
const updatedLayout = {
|
const updatedLayout = {
|
||||||
...layout,
|
...layout,
|
||||||
|
components: finalComponents,
|
||||||
screenResolution: newResolution,
|
screenResolution: newResolution,
|
||||||
};
|
};
|
||||||
|
|
||||||
setLayout(updatedLayout);
|
setLayout(updatedLayout);
|
||||||
saveToHistory(updatedLayout);
|
saveToHistory(updatedLayout);
|
||||||
|
|
||||||
toast.success(`해상도가 변경되었습니다.`, {
|
toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, {
|
||||||
description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`,
|
description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)");
|
console.log("✅ 해상도 변경 완료:", {
|
||||||
|
newResolution: `${newWidth}x${newHeight}`,
|
||||||
|
scaledComponents: finalComponents.length,
|
||||||
|
scaleX: `${(scaleX * 100).toFixed(2)}%`,
|
||||||
|
scaleY: `${(scaleY * 100).toFixed(2)}%`,
|
||||||
|
note: "모든 컴포넌트가 비율에 맞게 자동 조정됨",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[layout, saveToHistory, screenResolution],
|
[layout, saveToHistory, screenResolution],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -120,17 +120,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
||||||
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 휴지통 일괄삭제 관련 상태
|
// 일괄삭제 관련 상태
|
||||||
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
||||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
const [bulkDeleting, setBulkDeleting] = 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 [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
@ -485,7 +479,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 휴지통 체크박스 선택 처리
|
// 체크박스 선택 처리
|
||||||
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedScreenIds((prev) => [...prev, screenId]);
|
setSelectedScreenIds((prev) => [...prev, screenId]);
|
||||||
|
|
@ -494,7 +488,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 휴지통 전체 선택/해제
|
// 전체 선택/해제
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
||||||
|
|
@ -503,7 +497,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 휴지통 일괄삭제 실행
|
// 일괄삭제 실행
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedScreenIds.length === 0) {
|
if (selectedScreenIds.length === 0) {
|
||||||
alert("삭제할 화면을 선택해주세요.");
|
alert("삭제할 화면을 선택해주세요.");
|
||||||
|
|
@ -512,70 +506,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setBulkDeleteDialogOpen(true);
|
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 () => {
|
const confirmBulkDelete = async () => {
|
||||||
if (selectedScreenIds.length === 0) return;
|
if (selectedScreenIds.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -703,12 +633,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 구조 */}
|
{/* 탭 구조 */}
|
||||||
<Tabs value={activeTab} onValueChange={(value) => {
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
setActiveTab(value);
|
|
||||||
// 탭 전환 시 선택 상태 초기화
|
|
||||||
setSelectedActiveScreenIds([]);
|
|
||||||
setSelectedScreenIds([]);
|
|
||||||
}}>
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
||||||
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
||||||
|
|
@ -716,47 +641,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
{/* 활성 화면 탭 */}
|
{/* 활성 화면 탭 */}
|
||||||
<TabsContent value="active">
|
<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 이상) */}
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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>
|
<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>
|
||||||
|
|
@ -770,17 +659,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
||||||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||||||
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`}
|
}`}
|
||||||
onClick={() => onDesignScreen(screen)}
|
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">
|
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{screen.screenName}</div>
|
<div className="font-medium">{screen.screenName}</div>
|
||||||
|
|
@ -876,57 +757,24 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
<div className="space-y-4 lg:hidden">
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
{/* 선택 헤더 */}
|
{screens.map((screen) => (
|
||||||
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
|
<div
|
||||||
<div className="flex items-center gap-3">
|
key={screen.screenId}
|
||||||
<Checkbox
|
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
|
||||||
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
|
||||||
onCheckedChange={handleActiveSelectAll}
|
}`}
|
||||||
aria-label="전체 선택"
|
onClick={() => handleScreenSelect(screen)}
|
||||||
/>
|
>
|
||||||
<span className="text-sm text-muted-foreground">전체 선택</span>
|
{/* 헤더 */}
|
||||||
</div>
|
<div className="mb-4 flex items-start justify-between">
|
||||||
{selectedActiveScreenIds.length > 0 && (
|
<div className="flex-1">
|
||||||
<Button
|
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
||||||
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 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>
|
|
||||||
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
|
||||||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
||||||
|
|
@ -1015,12 +863,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filteredScreens.length === 0 && (
|
{filteredScreens.length === 0 && (
|
||||||
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -1378,13 +1225,13 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 휴지통 일괄삭제 확인 다이얼로그 */}
|
{/* 일괄삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-destructive">
|
<AlertDialogDescription className="text-destructive">
|
||||||
선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -1407,44 +1254,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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}>
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X, Loader2 } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface TabsWidgetProps {
|
interface TabsWidgetProps {
|
||||||
component: TabsComponent;
|
component: TabsComponent;
|
||||||
|
|
@ -49,8 +48,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||||
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
||||||
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
|
|
||||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
|
||||||
|
|
||||||
// 컴포넌트 탭 목록 변경 시 동기화
|
// 컴포넌트 탭 목록 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,14 +109,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
console.log("🔄 탭 변경:", tabId);
|
console.log("🔄 탭 변경:", tabId);
|
||||||
setSelectedTab(tabId);
|
setSelectedTab(tabId);
|
||||||
|
|
||||||
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
|
||||||
setMountedTabs(prev => {
|
|
||||||
if (prev.has(tabId)) return prev;
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.add(tabId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 해당 탭의 화면 로드
|
// 해당 탭의 화면 로드
|
||||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||||
|
|
@ -202,95 +191,72 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{visibleTabs.map((tab) => {
|
{visibleTabs.map((tab) => (
|
||||||
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
|
<TabsContent key={tab.id} value={tab.id} className="h-full">
|
||||||
const shouldRender = mountedTabs.has(tab.id);
|
{tab.screenId ? (
|
||||||
const isActive = selectedTab === tab.id;
|
loadingScreens[tab.screenId] ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
return (
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<TabsContent
|
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
||||||
key={tab.id}
|
</div>
|
||||||
value={tab.id}
|
) : screenLayouts[tab.screenId] ? (
|
||||||
forceMount // 🆕 DOM에 항상 유지
|
(() => {
|
||||||
className={cn(
|
const layoutData = screenLayouts[tab.screenId];
|
||||||
"h-full",
|
const { components = [], screenResolution } = layoutData;
|
||||||
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
|
|
||||||
)}
|
console.log("🎯 렌더링할 화면 데이터:", {
|
||||||
>
|
screenId: tab.screenId,
|
||||||
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
componentsCount: components.length,
|
||||||
{shouldRender && (
|
screenResolution,
|
||||||
<>
|
});
|
||||||
{tab.screenId ? (
|
|
||||||
loadingScreens[tab.screenId] ? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
) : screenLayouts[tab.screenId] ? (
|
|
||||||
(() => {
|
|
||||||
const layoutData = screenLayouts[tab.screenId];
|
|
||||||
const { components = [], screenResolution } = layoutData;
|
|
||||||
|
|
||||||
// 비활성 탭은 로그 생략
|
|
||||||
if (isActive) {
|
|
||||||
console.log("🎯 렌더링할 화면 데이터:", {
|
|
||||||
screenId: tab.screenId,
|
|
||||||
componentsCount: components.length,
|
|
||||||
screenResolution,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const designWidth = screenResolution?.width || 1920;
|
const designWidth = screenResolution?.width || 1920;
|
||||||
const designHeight = screenResolution?.height || 1080;
|
const designHeight = screenResolution?.height || 1080;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative h-full w-full overflow-auto bg-background"
|
className="relative h-full w-full overflow-auto bg-background"
|
||||||
style={{
|
style={{
|
||||||
minHeight: `${designHeight}px`,
|
minHeight: `${designHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
width: `${designWidth}px`,
|
||||||
|
height: `${designHeight}px`,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{components.map((component: any) => (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
key={component.id}
|
||||||
|
component={component}
|
||||||
|
allComponents={components}
|
||||||
|
screenInfo={{
|
||||||
|
id: tab.screenId,
|
||||||
|
tableName: layoutData.tableName,
|
||||||
}}
|
}}
|
||||||
>
|
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
|
||||||
<div
|
/>
|
||||||
className="relative"
|
))}
|
||||||
style={{
|
|
||||||
width: `${designWidth}px`,
|
|
||||||
height: `${designHeight}px`,
|
|
||||||
margin: "0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{components.map((component: any) => (
|
|
||||||
<InteractiveScreenViewerDynamic
|
|
||||||
key={component.id}
|
|
||||||
component={component}
|
|
||||||
allComponents={components}
|
|
||||||
screenInfo={{
|
|
||||||
id: tab.screenId,
|
|
||||||
tableName: layoutData.tableName,
|
|
||||||
}}
|
|
||||||
menuObjid={menuObjid}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
|
||||||
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</>
|
})()
|
||||||
)}
|
) : (
|
||||||
</TabsContent>
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
);
|
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
||||||
})}
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
|
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
|
|
||||||
onUpdate(value.valueId!, {
|
onUpdate(value.valueId!, {
|
||||||
valueLabel: valueLabel.trim(),
|
valueLabel: valueLabel.trim(),
|
||||||
description: description.trim() || undefined, // 빈 문자열 대신 undefined
|
description: description.trim(),
|
||||||
color: color === "none" ? null : color, // "none"은 null로 (배지 없음)
|
color: color,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,22 +112,6 @@ 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: {
|
getDeletedScreens: async (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { CardDisplayConfig } from "./types";
|
import { CardDisplayConfig } from "./types";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getFullImageUrl, apiClient } from "@/lib/api/client";
|
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
import { useModalDataStore } from "@/stores/modalDataStore";
|
import { useModalDataStore } from "@/stores/modalDataStore";
|
||||||
|
|
@ -52,14 +50,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
||||||
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
|
||||||
const [columnMeta, setColumnMeta] = useState<
|
|
||||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
|
||||||
>({});
|
|
||||||
const [categoryMappings, setCategoryMappings] = useState<
|
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
|
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -129,78 +119,44 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||||||
|
|
||||||
if (!tableNameToUse) {
|
if (!tableNameToUse) {
|
||||||
|
// console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
||||||
|
// tableName,
|
||||||
|
// componentTableName: component.componentConfig?.tableName,
|
||||||
|
// });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log("📋 CardDisplay: 사용할 테이블명", {
|
||||||
|
// tableName,
|
||||||
|
// componentTableName: component.componentConfig?.tableName,
|
||||||
|
// finalTableName: tableNameToUse,
|
||||||
|
// });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
// console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`);
|
||||||
|
|
||||||
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
|
// 테이블 데이터와 컬럼 정보를 병렬로 로드
|
||||||
const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([
|
const [dataResponse, columnsResponse] = await Promise.all([
|
||||||
tableTypeApi.getTableData(tableNameToUse, {
|
tableTypeApi.getTableData(tableNameToUse, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 50, // 카드 표시용으로 적당한 개수
|
size: 50, // 카드 표시용으로 적당한 개수
|
||||||
}),
|
}),
|
||||||
tableTypeApi.getColumns(tableNameToUse),
|
tableTypeApi.getColumns(tableNameToUse),
|
||||||
tableTypeApi.getColumnInputTypes(tableNameToUse),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, {
|
||||||
|
// total: dataResponse.total,
|
||||||
|
// dataLength: dataResponse.data.length,
|
||||||
|
// columnsLength: columnsResponse.length,
|
||||||
|
// sampleData: dataResponse.data.slice(0, 2),
|
||||||
|
// sampleColumns: columnsResponse.slice(0, 3),
|
||||||
|
// });
|
||||||
|
|
||||||
setLoadedTableData(dataResponse.data);
|
setLoadedTableData(dataResponse.data);
|
||||||
setLoadedTableColumns(columnsResponse);
|
setLoadedTableColumns(columnsResponse);
|
||||||
|
|
||||||
// 컬럼 메타 정보 설정 (inputType 포함)
|
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
|
||||||
inputTypesResponse.forEach((item: any) => {
|
|
||||||
meta[item.columnName || item.column_name] = {
|
|
||||||
webType: item.webType || item.web_type,
|
|
||||||
inputType: item.inputType || item.input_type,
|
|
||||||
codeCategory: item.codeCategory || item.code_category,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta);
|
|
||||||
setColumnMeta(meta);
|
|
||||||
|
|
||||||
// 카테고리 타입 컬럼 찾기 및 매핑 로드
|
|
||||||
const categoryColumns = Object.entries(meta)
|
|
||||||
.filter(([_, m]) => m.inputType === "category")
|
|
||||||
.map(([columnName]) => columnName);
|
|
||||||
|
|
||||||
console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns);
|
|
||||||
|
|
||||||
if (categoryColumns.length > 0) {
|
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
|
||||||
|
|
||||||
for (const columnName of categoryColumns) {
|
|
||||||
try {
|
|
||||||
console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`);
|
|
||||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
|
||||||
|
|
||||||
console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data);
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
||||||
response.data.data.forEach((item: any) => {
|
|
||||||
// API 응답 형식: valueCode, valueLabel (camelCase)
|
|
||||||
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
|
||||||
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
|
||||||
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
|
|
||||||
const rawColor = item.color ?? item.badge_color;
|
|
||||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
|
||||||
mapping[code] = { label, color };
|
|
||||||
console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`);
|
|
||||||
});
|
|
||||||
mappings[columnName] = mapping;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings);
|
|
||||||
setCategoryMappings(mappings);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ CardDisplay: 데이터 로딩 실패`, error);
|
console.error(`❌ CardDisplay: ${tableNameToUse} 데이터 로딩 실패`, error);
|
||||||
setLoadedTableData([]);
|
setLoadedTableData([]);
|
||||||
setLoadedTableColumns([]);
|
setLoadedTableColumns([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -277,17 +233,14 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
return String(data.id || data.objid || data.ID || index);
|
return String(data.id || data.objid || data.ID || index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제)
|
// 카드 선택 핸들러 (테이블 리스트와 동일한 로직)
|
||||||
const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
|
const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
|
||||||
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
|
const newSelectedRows = new Set(selectedRows);
|
||||||
const newSelectedRows = new Set<string>();
|
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
// 선택 시 해당 카드만 선택
|
|
||||||
newSelectedRows.add(cardKey);
|
newSelectedRows.add(cardKey);
|
||||||
|
} else {
|
||||||
|
newSelectedRows.delete(cardKey);
|
||||||
}
|
}
|
||||||
// checked가 false면 빈 Set (선택 해제)
|
|
||||||
|
|
||||||
setSelectedRows(newSelectedRows);
|
setSelectedRows(newSelectedRows);
|
||||||
|
|
||||||
// 선택된 카드 데이터 계산
|
// 선택된 카드 데이터 계산
|
||||||
|
|
@ -312,35 +265,35 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
additionalData: {},
|
additionalData: {},
|
||||||
}));
|
}));
|
||||||
useModalDataStore.getState().setData(tableNameToUse, modalItems);
|
useModalDataStore.getState().setData(tableNameToUse, modalItems);
|
||||||
console.log("[CardDisplay] modalDataStore에 데이터 저장:", {
|
console.log("✅ [CardDisplay] modalDataStore에 데이터 저장:", {
|
||||||
dataSourceId: tableNameToUse,
|
dataSourceId: tableNameToUse,
|
||||||
count: modalItems.length,
|
count: modalItems.length,
|
||||||
});
|
});
|
||||||
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
||||||
useModalDataStore.getState().clearData(tableNameToUse);
|
useModalDataStore.getState().clearData(tableNameToUse);
|
||||||
console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
|
console.log("🗑️ [CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||||
if (splitPanelContext && splitPanelPosition === "left") {
|
if (splitPanelContext && splitPanelPosition === "left") {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
splitPanelContext.setSelectedLeftData(data);
|
splitPanelContext.setSelectedLeftData(data);
|
||||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
||||||
data,
|
data,
|
||||||
parentDataMapping: splitPanelContext.parentDataMapping,
|
parentDataMapping: splitPanelContext.parentDataMapping,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (newSelectedRows.size === 0) {
|
||||||
splitPanelContext.setSelectedLeftData(null);
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
|
console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 초기화");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
}, [selectedRows, displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
||||||
|
|
||||||
const handleCardClick = useCallback((data: any, index: number) => {
|
const handleCardClick = useCallback((data: any, index: number) => {
|
||||||
const cardKey = getCardKey(data, index);
|
const cardKey = getCardKey(data, index);
|
||||||
const isCurrentlySelected = selectedRows.has(cardKey);
|
const isCurrentlySelected = selectedRows.has(cardKey);
|
||||||
|
|
||||||
// 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
|
// 선택 토글
|
||||||
handleCardSelection(cardKey, data, !isCurrentlySelected);
|
handleCardSelection(cardKey, data, !isCurrentlySelected);
|
||||||
|
|
||||||
if (componentConfig.onCardClick) {
|
if (componentConfig.onCardClick) {
|
||||||
|
|
@ -435,80 +388,17 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
return text.substring(0, maxLength) + "...";
|
return text.substring(0, maxLength) + "...";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환)
|
// 컬럼 매핑에서 값 가져오기
|
||||||
const getColumnValueAsString = (data: any, columnName?: string): string => {
|
const getColumnValue = (data: any, columnName?: string) => {
|
||||||
if (!columnName) return "";
|
if (!columnName) return "";
|
||||||
const value = data[columnName];
|
return data[columnName] || "";
|
||||||
if (value === null || value === undefined || value === "") return "";
|
|
||||||
|
|
||||||
// 카테고리 타입인 경우 매핑된 라벨 반환
|
|
||||||
const meta = columnMeta[columnName];
|
|
||||||
if (meta?.inputType === "category") {
|
|
||||||
const mapping = categoryMappings[columnName];
|
|
||||||
const valueStr = String(value);
|
|
||||||
const categoryData = mapping?.[valueStr];
|
|
||||||
return categoryData?.label || valueStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시)
|
|
||||||
const getColumnValue = (data: any, columnName?: string): React.ReactNode => {
|
|
||||||
if (!columnName) return "";
|
|
||||||
const value = data[columnName];
|
|
||||||
if (value === null || value === undefined || value === "") return "";
|
|
||||||
|
|
||||||
// 카테고리 타입인 경우 매핑된 라벨과 배지로 표시
|
|
||||||
const meta = columnMeta[columnName];
|
|
||||||
if (meta?.inputType === "category") {
|
|
||||||
const mapping = categoryMappings[columnName];
|
|
||||||
const valueStr = String(value);
|
|
||||||
const categoryData = mapping?.[valueStr];
|
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
|
||||||
const displayColor = categoryData?.color;
|
|
||||||
|
|
||||||
// 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음)
|
|
||||||
if (!displayColor || displayColor === "none") {
|
|
||||||
return displayLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: displayColor,
|
|
||||||
borderColor: displayColor,
|
|
||||||
}}
|
|
||||||
className="text-white text-xs"
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
// 컬럼명을 라벨로 변환하는 헬퍼 함수
|
||||||
const getColumnLabel = (columnName: string) => {
|
const getColumnLabel = (columnName: string) => {
|
||||||
if (!actualTableColumns || actualTableColumns.length === 0) {
|
if (!actualTableColumns || actualTableColumns.length === 0) return columnName;
|
||||||
// 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환
|
const column = actualTableColumns.find((col) => col.columnName === columnName);
|
||||||
return formatColumnName(columnName);
|
return column?.columnLabel || 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());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
|
||||||
|
|
@ -607,37 +497,21 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayData.map((data, index) => {
|
displayData.map((data, index) => {
|
||||||
// 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시)
|
// 타이틀, 서브타이틀, 설명 값 결정 (원래 카드 레이아웃과 동일한 로직)
|
||||||
const titleValue =
|
const titleValue =
|
||||||
getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
getColumnValue(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
|
||||||
|
|
||||||
const subtitleValue =
|
const subtitleValue =
|
||||||
getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) ||
|
getColumnValue(data, componentConfig.columnMapping?.subtitleColumn) ||
|
||||||
getAutoFallbackValue(data, "subtitle");
|
getAutoFallbackValue(data, "subtitle");
|
||||||
|
|
||||||
const descriptionValue =
|
const descriptionValue =
|
||||||
getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) ||
|
getColumnValue(data, componentConfig.columnMapping?.descriptionColumn) ||
|
||||||
getAutoFallbackValue(data, "description");
|
getAutoFallbackValue(data, "description");
|
||||||
|
|
||||||
// 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시
|
const imageValue = componentConfig.columnMapping?.imageColumn
|
||||||
const imageColumn = componentConfig.columnMapping?.imageColumn ||
|
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
|
||||||
Object.keys(data).find(key => {
|
: data.avatar || data.image || "";
|
||||||
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 cardKey = getCardKey(data, index);
|
||||||
const isCardSelected = selectedRows.has(cardKey);
|
const isCardSelected = selectedRows.has(cardKey);
|
||||||
|
|
@ -652,100 +526,78 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
boxShadow: isCardSelected
|
boxShadow: isCardSelected
|
||||||
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
|
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
|
||||||
: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
||||||
flexDirection: "row", // 가로 배치
|
|
||||||
}}
|
}}
|
||||||
className="card-hover group cursor-pointer transition-all duration-150"
|
className="card-hover group cursor-pointer transition-all duration-150"
|
||||||
onClick={() => handleCardClick(data, index)}
|
onClick={() => handleCardClick(data, index)}
|
||||||
>
|
>
|
||||||
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
|
{/* 카드 이미지 */}
|
||||||
{shouldShowImage && (
|
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
||||||
<div className="flex-shrink-0 flex items-center justify-center mr-4">
|
<div className="mb-2 flex justify-center">
|
||||||
{imageUrl ? (
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||||
<img
|
<span className="text-lg text-primary">👤</span>
|
||||||
src={imageUrl}
|
</div>
|
||||||
alt={titleValue || "이미지"}
|
</div>
|
||||||
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";
|
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
|
||||||
}}
|
<div className="mb-2 flex items-center gap-2 flex-wrap">
|
||||||
/>
|
{componentConfig.cardStyle?.showTitle && (
|
||||||
) : (
|
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
|
)}
|
||||||
<span className="text-2xl text-primary">👤</span>
|
{componentConfig.cardStyle?.showSubtitle && subtitleValue && (
|
||||||
</div>
|
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 우측 컨텐츠 영역 */}
|
{/* 카드 설명 */}
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
{componentConfig.cardStyle?.showDescription && (
|
||||||
{/* 타이틀 + 서브타이틀 */}
|
<div className="mb-2 flex-1">
|
||||||
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
<div className="mb-1 flex items-center gap-2 flex-wrap">
|
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||||
{componentConfig.cardStyle?.showTitle && (
|
</p>
|
||||||
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
|
</div>
|
||||||
)}
|
)}
|
||||||
{componentConfig.cardStyle?.showSubtitle && subtitleValue && (
|
|
||||||
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
|
{/* 추가 표시 컬럼들 - 가로 배치 */}
|
||||||
)}
|
{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">
|
||||||
|
{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 className="font-medium text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 추가 표시 컬럼들 - 가로 배치 */}
|
{/* 카드 액션 */}
|
||||||
{componentConfig.columnMapping?.displayColumns &&
|
<div className="mt-2 flex justify-end space-x-2">
|
||||||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
<button
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
onClick={(e) => {
|
||||||
const value = getColumnValue(data, columnName);
|
e.stopPropagation();
|
||||||
if (!value) return null;
|
handleCardView(data);
|
||||||
|
}}
|
||||||
return (
|
>
|
||||||
<div key={idx} className="flex items-center gap-1">
|
상세보기
|
||||||
<span>{getColumnLabel(columnName)}:</span>
|
</button>
|
||||||
<span className="font-medium text-foreground">{value}</span>
|
<button
|
||||||
</div>
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
);
|
onClick={(e) => {
|
||||||
})}
|
e.stopPropagation();
|
||||||
</div>
|
handleCardEdit(data);
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
{/* 카드 설명 */}
|
편집
|
||||||
{componentConfig.cardStyle?.showDescription && descriptionValue && (
|
</button>
|
||||||
<div className="mt-1 flex-1">
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 카드 액션 - 설정에 따라 표시 */}
|
|
||||||
{(componentConfig.cardStyle?.showActions ?? true) && (
|
|
||||||
<div className="mt-2 flex justify-end space-x-2">
|
|
||||||
{(componentConfig.cardStyle?.showViewButton ?? true) && (
|
|
||||||
<button
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCardView(data);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
상세보기
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(componentConfig.cardStyle?.showEditButton ?? true) && (
|
|
||||||
<button
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCardEdit(data);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
편집
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -769,48 +621,16 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{Object.entries(selectedData)
|
{Object.entries(selectedData)
|
||||||
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => (
|
||||||
// 카테고리 타입인 경우 배지로 표시
|
<div key={key} className="bg-muted rounded-lg p-3">
|
||||||
const meta = columnMeta[key];
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||||
let displayValue: React.ReactNode = String(value);
|
{key.replace(/_/g, ' ')}
|
||||||
|
|
||||||
if (meta?.inputType === "category") {
|
|
||||||
const mapping = categoryMappings[key];
|
|
||||||
const valueStr = String(value);
|
|
||||||
const categoryData = mapping?.[valueStr];
|
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
|
||||||
const displayColor = categoryData?.color;
|
|
||||||
|
|
||||||
// 색상이 있고 "none"이 아닌 경우에만 배지로 표시
|
|
||||||
if (displayColor && displayColor !== "none") {
|
|
||||||
displayValue = (
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: displayColor,
|
|
||||||
borderColor: displayColor,
|
|
||||||
}}
|
|
||||||
className="text-white"
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 배지 없음: 일반 텍스트로 표시
|
|
||||||
displayValue = displayLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="bg-muted rounded-lg p-3">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
|
||||||
{getColumnLabel(key)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-foreground break-words">
|
|
||||||
{displayValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="text-sm font-medium text-foreground break-words">
|
||||||
})
|
{String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,37 +277,6 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
액션 버튼 표시
|
액션 버튼 표시
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */}
|
|
||||||
{(config.cardStyle?.showActions ?? true) && (
|
|
||||||
<div className="ml-4 space-y-2 border-l-2 border-gray-200 pl-3">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showViewButton"
|
|
||||||
checked={config.cardStyle?.showViewButton ?? true}
|
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showViewButton", e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="showViewButton" className="text-xs text-gray-600">
|
|
||||||
상세보기 버튼
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showEditButton"
|
|
||||||
checked={config.cardStyle?.showEditButton ?? true}
|
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showEditButton", e.target.checked)}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="showEditButton" className="text-xs text-gray-600">
|
|
||||||
편집 버튼
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ export interface CardStyleConfig {
|
||||||
maxDescriptionLength?: number;
|
maxDescriptionLength?: number;
|
||||||
imagePosition?: "top" | "left" | "right";
|
imagePosition?: "top" | "left" | "right";
|
||||||
imageSize?: "small" | "medium" | "large";
|
imageSize?: "small" | "medium" | "large";
|
||||||
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
showActions?: boolean; // 액션 버튼 표시 여부
|
||||||
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
|
||||||
showEditButton?: boolean; // 편집 버튼 표시 여부
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -732,16 +732,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
allOptions.map((option, index) => {
|
allOptions.map((option, index) => {
|
||||||
const isOptionSelected = selectedValues.includes(option.value);
|
const isSelected = selectedValues.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${option.value}-${index}`}
|
key={`${option.value}-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||||
isOptionSelected && "bg-blue-50 font-medium"
|
isSelected && "bg-blue-50 font-medium"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newVals = isOptionSelected
|
const newVals = isSelected
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
: [...selectedValues, option.value];
|
: [...selectedValues, option.value];
|
||||||
setSelectedValues(newVals);
|
setSelectedValues(newVals);
|
||||||
|
|
@ -754,21 +754,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isOptionSelected}
|
checked={isSelected}
|
||||||
value={option.value}
|
onChange={() => {}}
|
||||||
onChange={(e) => {
|
className="h-4 w-4"
|
||||||
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
|
|
||||||
e.stopPropagation();
|
|
||||||
const newVals = isOptionSelected
|
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
|
||||||
: [...selectedValues, option.value];
|
|
||||||
setSelectedValues(newVals);
|
|
||||||
const newValue = newVals.join(",");
|
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
|
||||||
onFormDataChange(component.columnName, newValue);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 pointer-events-auto"
|
|
||||||
/>
|
/>
|
||||||
<span>{option.label || option.value}</span>
|
<span>{option.label || option.value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1678,4 +1678,3 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
## 결론
|
## 결론
|
||||||
|
|
||||||
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -525,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
- ✅ 매핑 엔진 완성
|
- ✅ 매핑 엔진 완성
|
||||||
|
|
||||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -512,4 +512,3 @@ function ScreenViewPage() {
|
||||||
**충돌 위험도: 낮음 (🟢)**
|
**충돌 위험도: 낮음 (🟢)**
|
||||||
|
|
||||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue