feat: 화면 복사 기능 개선 및 버튼 모달 설정 수정
## 주요 변경사항 ### 1. 화면 복사 기능 강화 - 최고 관리자가 다른 회사로 화면 복사 가능하도록 개선 - 메인 화면과 연결된 모달 화면 자동 감지 및 일괄 복사 - 복사 시 버튼의 targetScreenId 자동 업데이트 - 일괄 이름 변경 기능 추가 (복사본 텍스트 제거) - 중복 화면명 체크 기능 추가 #### 백엔드 (screenManagementService.ts) - generateMultipleScreenCodes: 여러 화면 코드 일괄 생성 (Advisory Lock 사용) - detectLinkedModalScreens: edit 액션도 모달로 감지하도록 개선 - checkDuplicateScreenName: 중복 화면명 체크 API 추가 - copyScreenWithModals: 메인+모달 일괄 복사 및 버튼 업데이트 - updateButtonTargetScreenIds: 복사된 모달로 버튼 targetScreenId 업데이트 - updated_date 컬럼 제거 (screen_layouts 테이블에 존재하지 않음) #### 프론트엔드 (CopyScreenModal.tsx) - 회사 선택 UI 추가 (최고 관리자 전용) - 연결된 모달 화면 자동 감지 및 표시 - 일괄 이름 변경 기능 (텍스트 제거/추가) - 실시간 미리보기 - 중복 화면명 체크 ### 2. 버튼 설정 모달 화면 선택 개선 - 편집 중인 화면의 company_code 기준으로 화면 목록 조회 - 최고 관리자가 다른 회사 화면 편집 시 해당 회사의 모달 화면만 표시 - targetScreenId 문자열/숫자 타입 불일치 수정 #### 백엔드 (screenManagementController.ts) - getScreens API에 companyCode 쿼리 파라미터 추가 - 최고 관리자는 다른 회사의 화면 목록 조회 가능 #### 프론트엔드 - ButtonConfigPanel: currentScreenCompanyCode props 추가 - DetailSettingsPanel: currentScreenCompanyCode 전달 - UnifiedPropertiesPanel: currentScreenCompanyCode 전달 - ScreenDesigner: selectedScreen.companyCode 전달 - targetScreenId 비교 시 parseInt 처리 (문자열→숫자) ### 3. 카테고리 메뉴별 컬럼 분리 기능 - 메뉴별로 카테고리 컬럼을 독립적으로 관리 - 카테고리 컬럼 추가/삭제 시 메뉴 스코프 적용 ## 수정된 파일 - backend-node/src/services/screenManagementService.ts - backend-node/src/controllers/screenManagementController.ts - backend-node/src/routes/screenManagementRoutes.ts - frontend/components/screen/CopyScreenModal.tsx - frontend/components/screen/config-panels/ButtonConfigPanel.tsx - frontend/components/screen/panels/DetailSettingsPanel.tsx - frontend/components/screen/panels/UnifiedPropertiesPanel.tsx - frontend/components/screen/ScreenDesigner.tsx - frontend/lib/api/screen.ts
This commit is contained in:
@@ -2595,6 +2595,72 @@ export const createCompany = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/companies/:companyCode
|
||||
* 회사 정보 조회 API
|
||||
*/
|
||||
export const getCompanyByCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
|
||||
logger.info("회사 정보 조회 요청", {
|
||||
companyCode,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// Raw Query로 회사 정보 조회
|
||||
const company = await queryOne<any>(
|
||||
`SELECT * FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
if (!company) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "해당 회사를 찾을 수 없습니다.",
|
||||
errorCode: "COMPANY_NOT_FOUND",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("회사 정보 조회 성공", {
|
||||
companyCode: company.company_code,
|
||||
companyName: company.company_name,
|
||||
});
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
message: "회사 정보 조회 성공",
|
||||
data: {
|
||||
companyCode: company.company_code,
|
||||
companyName: company.company_name,
|
||||
businessRegistrationNumber: company.business_registration_number,
|
||||
representativeName: company.representative_name,
|
||||
representativePhone: company.representative_phone,
|
||||
email: company.email,
|
||||
website: company.website,
|
||||
address: company.address,
|
||||
status: company.status,
|
||||
writer: company.writer,
|
||||
regdate: company.regdate,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회사 정보 조회 중 오류가 발생했습니다.",
|
||||
errorCode: "COMPANY_GET_ERROR",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/admin/companies/:companyCode
|
||||
* 회사 정보 수정 API
|
||||
|
||||
@@ -5,11 +5,23 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||
// 화면 목록 조회
|
||||
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
const { page = 1, size = 20, searchTerm } = req.query;
|
||||
const userCompanyCode = (req.user as any).companyCode;
|
||||
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
|
||||
|
||||
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
|
||||
// 아니면 현재 사용자의 companyCode 사용
|
||||
const targetCompanyCode = (companyCode as string) || userCompanyCode;
|
||||
|
||||
// 최고 관리자가 아닌 경우 자신의 회사 코드만 사용 가능
|
||||
if (userCompanyCode !== "*" && targetCompanyCode !== userCompanyCode) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 화면을 조회할 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.getScreensByCompany(
|
||||
companyCode,
|
||||
targetCompanyCode,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string)
|
||||
);
|
||||
@@ -325,7 +337,118 @@ export const bulkPermanentDeleteScreens = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 복사
|
||||
// 연결된 모달 화면 감지 (화면 복사 전 확인)
|
||||
export const detectLinkedScreens = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const linkedScreens = await screenManagementService.detectLinkedModalScreens(
|
||||
parseInt(id)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: linkedScreens,
|
||||
message: linkedScreens.length > 0
|
||||
? `${linkedScreens.length}개의 연결된 모달 화면을 감지했습니다.`
|
||||
: "연결된 모달 화면이 없습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("연결된 화면 감지 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "연결된 화면 감지에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면명 중복 체크
|
||||
export const checkDuplicateScreenName = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, screenName } = req.body;
|
||||
|
||||
if (!companyCode || !screenName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "companyCode와 screenName은 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate =
|
||||
await screenManagementService.checkDuplicateScreenName(
|
||||
companyCode,
|
||||
screenName
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { isDuplicate },
|
||||
message: isDuplicate
|
||||
? "이미 존재하는 화면명입니다."
|
||||
: "사용 가능한 화면명입니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("화면명 중복 체크 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "화면명 중복 체크에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 일괄 복사 (메인 + 모달 화면들)
|
||||
export const copyScreenWithModals = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { mainScreen, modalScreens, targetCompanyCode } = req.body;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
|
||||
if (!mainScreen || !mainScreen.screenName || !mainScreen.screenCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "메인 화면 정보(screenName, screenCode)가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyScreenWithModals({
|
||||
sourceScreenId: parseInt(id),
|
||||
companyCode,
|
||||
userId,
|
||||
targetCompanyCode, // 최고 관리자가 다른 회사로 복사할 때 사용
|
||||
mainScreen: {
|
||||
screenName: mainScreen.screenName,
|
||||
screenCode: mainScreen.screenCode,
|
||||
description: mainScreen.description,
|
||||
},
|
||||
modalScreens: modalScreens || [],
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `화면 복사가 완료되었습니다. (메인 1개 + 모달 ${result.modalScreens.length}개)`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("화면 일괄 복사 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "화면 일괄 복사에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 복사 (단일 - 하위 호환용)
|
||||
export const copyScreen = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
@@ -495,6 +618,50 @@ export const generateScreenCode = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 여러 개의 화면 코드 일괄 생성
|
||||
export const generateMultipleScreenCodes = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { companyCode, count } = req.body;
|
||||
|
||||
if (!companyCode || typeof companyCode !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드(companyCode)는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!count || typeof count !== "number" || count < 1 || count > 100) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "count는 1~100 사이의 숫자여야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const screenCodes =
|
||||
await screenManagementService.generateMultipleScreenCodes(
|
||||
companyCode,
|
||||
count
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { screenCodes },
|
||||
message: `${count}개의 화면 코드가 생성되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("화면 코드 일괄 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "화면 코드 일괄 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면-메뉴 할당
|
||||
export const assignScreenToMenu = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
||||
@@ -268,3 +268,206 @@ export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Resp
|
||||
}
|
||||
};
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 조회
|
||||
*
|
||||
* GET /api/categories/column-mapping/:tableName/:menuObjid
|
||||
*
|
||||
* 특정 테이블과 메뉴에 대한 논리적 컬럼명 → 물리적 컬럼명 매핑을 조회합니다.
|
||||
*
|
||||
* @returns { logical_column: physical_column } 형태의 매핑 객체
|
||||
*/
|
||||
export const getColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, menuObjid } = req.params;
|
||||
|
||||
if (!tableName || !menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 menuObjid는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("컬럼 매핑 조회", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const mapping = await tableCategoryValueService.getColumnMapping(
|
||||
tableName,
|
||||
Number(menuObjid),
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: mapping,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 매핑 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 생성/수정
|
||||
*
|
||||
* POST /api/categories/column-mapping
|
||||
*
|
||||
* Body:
|
||||
* - tableName: 테이블명
|
||||
* - logicalColumnName: 논리적 컬럼명 (예: status_stock)
|
||||
* - physicalColumnName: 물리적 컬럼명 (예: status)
|
||||
* - menuObjid: 메뉴 OBJID
|
||||
* - description: 설명 (선택사항)
|
||||
*/
|
||||
export const createColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
menuObjid,
|
||||
description,
|
||||
} = req.body;
|
||||
|
||||
// 입력 검증
|
||||
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("컬럼 매핑 생성", {
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const mapping = await tableCategoryValueService.createColumnMapping(
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
Number(menuObjid),
|
||||
companyCode,
|
||||
userId,
|
||||
description
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: mapping,
|
||||
message: "컬럼 매핑이 생성되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "컬럼 매핑 생성 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 논리적 컬럼 목록 조회
|
||||
*
|
||||
* GET /api/categories/logical-columns/:tableName/:menuObjid
|
||||
*
|
||||
* 특정 테이블과 메뉴에 대한 논리적 컬럼 목록을 조회합니다.
|
||||
* (카테고리 값 추가 시 컬럼 선택용)
|
||||
*/
|
||||
export const getLogicalColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, menuObjid } = req.params;
|
||||
|
||||
if (!tableName || !menuObjid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 menuObjid는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("논리적 컬럼 목록 조회", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const columns = await tableCategoryValueService.getLogicalColumns(
|
||||
tableName,
|
||||
Number(menuObjid),
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: columns,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "논리적 컬럼 목록 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 삭제
|
||||
*
|
||||
* DELETE /api/categories/column-mapping/:mappingId
|
||||
*/
|
||||
export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { mappingId } = req.params;
|
||||
|
||||
if (!mappingId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "mappingId는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("컬럼 매핑 삭제", {
|
||||
mappingId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
await tableCategoryValueService.deleteColumnMapping(
|
||||
Number(mappingId),
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "컬럼 매핑이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
saveUser, // 사용자 등록/수정
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
createCompany, // 회사 등록
|
||||
updateCompany, // 회사 수정
|
||||
deleteCompany, // 회사 삭제
|
||||
@@ -60,6 +61,7 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
// 회사 관리 API
|
||||
router.get("/companies", getCompanyList);
|
||||
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
|
||||
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회
|
||||
router.post("/companies", createCompany); // 회사 등록
|
||||
router.put("/companies/:companyCode", updateCompany); // 회사 수정
|
||||
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
||||
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
permanentDeleteScreen,
|
||||
getDeletedScreens,
|
||||
bulkPermanentDeleteScreens,
|
||||
detectLinkedScreens,
|
||||
checkDuplicateScreenName,
|
||||
copyScreen,
|
||||
copyScreenWithModals,
|
||||
getTables,
|
||||
getTableInfo,
|
||||
getTableColumns,
|
||||
saveLayout,
|
||||
getLayout,
|
||||
generateScreenCode,
|
||||
generateMultipleScreenCodes,
|
||||
assignScreenToMenu,
|
||||
getScreensByMenu,
|
||||
unassignScreenFromMenu,
|
||||
@@ -40,7 +44,10 @@ router.put("/screens/:id", updateScreen);
|
||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||
router.post("/screens/:id/copy", copyScreen);
|
||||
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||
router.post("/screens/:id/copy-with-modals", copyScreenWithModals); // 메인 + 모달 일괄 복사
|
||||
|
||||
// 휴지통 관리
|
||||
router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록
|
||||
@@ -51,6 +58,9 @@ router.delete("/screens/trash/bulk", bulkPermanentDeleteScreens); // 일괄 영
|
||||
// 화면 코드 자동 생성
|
||||
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
||||
|
||||
// 여러 개의 화면 코드 일괄 생성
|
||||
router.post("/generate-screen-codes", generateMultipleScreenCodes);
|
||||
|
||||
// 테이블 관리
|
||||
router.get("/tables", getTables);
|
||||
router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화)
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
deleteCategoryValue,
|
||||
bulkDeleteCategoryValues,
|
||||
reorderCategoryValues,
|
||||
getColumnMapping,
|
||||
createColumnMapping,
|
||||
getLogicalColumns,
|
||||
deleteColumnMapping,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
@@ -36,5 +40,21 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||
// 카테고리 값 순서 변경
|
||||
router.post("/values/reorder", reorderCategoryValues);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
||||
// 컬럼 매핑 조회
|
||||
router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping);
|
||||
|
||||
// 논리적 컬럼 목록 조회
|
||||
router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
|
||||
|
||||
// 컬럼 매핑 생성/수정
|
||||
router.post("/column-mapping", createColumnMapping);
|
||||
|
||||
// 컬럼 매핑 삭제
|
||||
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -539,29 +539,43 @@ export class RoleService {
|
||||
|
||||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
*/
|
||||
/**
|
||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||
*
|
||||
* @param companyCode - 회사 코드
|
||||
* - undefined: 최고 관리자 - 모든 회사의 모든 메뉴 조회
|
||||
* - "*": 최고 관리자의 공통 메뉴만 조회 (최고 관리자 전용)
|
||||
* - "COMPANY_X": 해당 회사 메뉴만 조회 (공통 메뉴 제외)
|
||||
*
|
||||
* 중요:
|
||||
* - 공통 메뉴(company_code = "*")는 최고 관리자 전용 메뉴입니다.
|
||||
* - menu_type = 2 (화면)는 제외하고 메뉴만 조회합니다.
|
||||
*/
|
||||
static async getAllMenus(companyCode?: string): Promise<any[]> {
|
||||
try {
|
||||
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
|
||||
|
||||
let whereConditions: string[] = ["status = 'active'"];
|
||||
let whereConditions: string[] = [
|
||||
"status = 'active'",
|
||||
"menu_type != 2" // 화면 제외, 메뉴만 조회
|
||||
];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (선택적)
|
||||
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
|
||||
// 회사 코드 필터 (선택적)
|
||||
if (companyCode) {
|
||||
// 특정 회사 메뉴만 조회 (공통 메뉴 제외)
|
||||
// 회사 코드에 따른 필터링
|
||||
if (companyCode === undefined) {
|
||||
// 최고 관리자: 모든 메뉴 조회
|
||||
logger.info("📋 최고 관리자 모드: 모든 메뉴 조회");
|
||||
} else if (companyCode === "*") {
|
||||
// 공통 메뉴만 조회
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push("*");
|
||||
paramIndex++;
|
||||
logger.info("📋 공통 메뉴만 조회");
|
||||
} else {
|
||||
// 특정 회사: 해당 회사 메뉴 + 공통 메뉴 조회
|
||||
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode });
|
||||
} else {
|
||||
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
|
||||
logger.info("📋 회사별 필터 적용 (해당 회사 + 공통 메뉴)", { companyCode });
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
@@ -573,13 +587,19 @@ export class RoleService {
|
||||
menu_name_eng AS "menuNameEng",
|
||||
menu_code AS "menuCode",
|
||||
menu_url AS "menuUrl",
|
||||
menu_type AS "menuType",
|
||||
CAST(menu_type AS TEXT) AS "menuType",
|
||||
parent_obj_id AS "parentObjid",
|
||||
seq AS "sortOrder",
|
||||
company_code AS "companyCode"
|
||||
FROM menu_info
|
||||
WHERE ${whereClause}
|
||||
ORDER BY seq, menu_name_kor
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN parent_obj_id = 0 OR parent_obj_id IS NULL THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
seq,
|
||||
menu_name_kor
|
||||
`;
|
||||
|
||||
logger.info("🔍 SQL 쿼리 실행", {
|
||||
@@ -592,8 +612,9 @@ export class RoleService {
|
||||
|
||||
logger.info("✅ 메뉴 목록 조회 성공", {
|
||||
count: result.length,
|
||||
companyCode,
|
||||
menus: result.map((m) => ({
|
||||
companyCode: companyCode || "전체",
|
||||
companyCodes: [...new Set(result.map((m) => m.companyCode))],
|
||||
menus: result.slice(0, 5).map((m) => ({
|
||||
objid: m.objid,
|
||||
name: m.menuName,
|
||||
code: m.menuCode,
|
||||
|
||||
@@ -23,8 +23,9 @@ interface CopyScreenRequest {
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
description?: string;
|
||||
companyCode: string;
|
||||
createdBy: string;
|
||||
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
|
||||
userId: string;
|
||||
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
|
||||
}
|
||||
|
||||
// 백엔드에서 사용할 테이블 정보 타입
|
||||
@@ -1841,37 +1842,191 @@ export class ScreenManagementService {
|
||||
|
||||
/**
|
||||
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
|
||||
* 동시성 문제 방지: Advisory Lock 사용
|
||||
*/
|
||||
async generateScreenCode(companyCode: string): Promise<string> {
|
||||
// 해당 회사의 기존 화면 코드들 조회 (Raw Query)
|
||||
const existingScreens = await query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions
|
||||
WHERE company_code = $1 AND screen_code LIKE $2
|
||||
ORDER BY screen_code DESC`,
|
||||
[companyCode, `${companyCode}%`]
|
||||
);
|
||||
return await transaction(async (client) => {
|
||||
// 회사 코드를 숫자로 변환하여 advisory lock ID로 사용
|
||||
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||
|
||||
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||
|
||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
);
|
||||
// 해당 회사의 기존 화면 코드들 조회
|
||||
const existingScreens = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions
|
||||
WHERE company_code = $1 AND screen_code LIKE $2
|
||||
ORDER BY screen_code DESC
|
||||
LIMIT 10`,
|
||||
[companyCode, `${companyCode}%`]
|
||||
);
|
||||
|
||||
for (const screen of existingScreens) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
);
|
||||
|
||||
for (const screen of existingScreens.rows) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||
const nextNumber = maxNumber + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
|
||||
const newCode = `${companyCode}_${paddedNumber}`;
|
||||
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
||||
|
||||
return newCode;
|
||||
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 개의 화면 코드를 한 번에 생성 (중복 방지)
|
||||
* 한 트랜잭션 내에서 순차적으로 생성하여 중복 방지
|
||||
*/
|
||||
async generateMultipleScreenCodes(
|
||||
companyCode: string,
|
||||
count: number
|
||||
): Promise<string[]> {
|
||||
return await transaction(async (client) => {
|
||||
// Advisory lock 획득
|
||||
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||
|
||||
// 현재 최대 번호 조회
|
||||
const existingScreens = await client.query<{ screen_code: string }>(
|
||||
`SELECT screen_code FROM screen_definitions
|
||||
WHERE company_code = $1 AND screen_code LIKE $2
|
||||
ORDER BY screen_code DESC
|
||||
LIMIT 10`,
|
||||
[companyCode, `${companyCode}%`]
|
||||
);
|
||||
|
||||
let maxNumber = 0;
|
||||
const pattern = new RegExp(
|
||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||
);
|
||||
|
||||
for (const screen of existingScreens.rows) {
|
||||
const match = screen.screen_code.match(pattern);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// count개의 코드를 순차적으로 생성
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextNumber = maxNumber + i + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
codes.push(`${companyCode}_${paddedNumber}`);
|
||||
}
|
||||
|
||||
console.log(`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(', ')}]`);
|
||||
|
||||
return codes;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면명 중복 체크
|
||||
* 같은 회사 내에서 동일한 화면명이 있는지 확인
|
||||
*/
|
||||
async checkDuplicateScreenName(
|
||||
companyCode: string,
|
||||
screenName: string
|
||||
): Promise<boolean> {
|
||||
const result = await query<any>(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM screen_definitions
|
||||
WHERE company_code = $1
|
||||
AND screen_name = $2
|
||||
AND deleted_date IS NULL`,
|
||||
[companyCode, screenName]
|
||||
);
|
||||
|
||||
const count = parseInt(result[0]?.count || "0", 10);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면에 연결된 모달 화면들을 자동 감지
|
||||
* 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출
|
||||
*/
|
||||
async detectLinkedModalScreens(
|
||||
screenId: number
|
||||
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
|
||||
// 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
const linkedScreenIds = new Set<number>();
|
||||
|
||||
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
|
||||
// edit 액션도 수정 폼 모달을 열기 때문에 포함
|
||||
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
|
||||
const targetScreenId = parseInt(action.targetScreenId);
|
||||
if (!isNaN(targetScreenId)) {
|
||||
linkedScreenIds.add(targetScreenId);
|
||||
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 오류 등은 무시하고 계속 진행
|
||||
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||
const nextNumber = maxNumber + 1;
|
||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||
// 감지된 화면 ID들의 정보 조회
|
||||
if (linkedScreenIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return `${companyCode}_${paddedNumber}`;
|
||||
const screenIds = Array.from(linkedScreenIds);
|
||||
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const linkedScreens = await query<any>(
|
||||
`SELECT screen_id, screen_name, screen_code
|
||||
FROM screen_definitions
|
||||
WHERE screen_id IN (${placeholders})
|
||||
AND deleted_date IS NULL
|
||||
ORDER BY screen_name`,
|
||||
screenIds
|
||||
);
|
||||
|
||||
return linkedScreens.map((s) => ({
|
||||
screenId: s.screen_id,
|
||||
screenName: s.screen_name,
|
||||
screenCode: s.screen_code,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1884,11 +2039,31 @@ export class ScreenManagementService {
|
||||
// 트랜잭션으로 처리
|
||||
return await transaction(async (client) => {
|
||||
// 1. 원본 화면 정보 조회
|
||||
// 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음
|
||||
let sourceScreenQuery: string;
|
||||
let sourceScreenParams: any[];
|
||||
|
||||
if (copyData.companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사의 화면 조회 가능
|
||||
sourceScreenQuery = `
|
||||
SELECT * FROM screen_definitions
|
||||
WHERE screen_id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
sourceScreenParams = [sourceScreenId];
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 화면만 조회 가능
|
||||
sourceScreenQuery = `
|
||||
SELECT * FROM screen_definitions
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
LIMIT 1
|
||||
`;
|
||||
sourceScreenParams = [sourceScreenId, copyData.companyCode];
|
||||
}
|
||||
|
||||
const sourceScreens = await client.query<any>(
|
||||
`SELECT * FROM screen_definitions
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[sourceScreenId, copyData.companyCode]
|
||||
sourceScreenQuery,
|
||||
sourceScreenParams
|
||||
);
|
||||
|
||||
if (sourceScreens.rows.length === 0) {
|
||||
@@ -1897,19 +2072,24 @@ export class ScreenManagementService {
|
||||
|
||||
const sourceScreen = sourceScreens.rows[0];
|
||||
|
||||
// 2. 화면 코드 중복 체크
|
||||
// 2. 대상 회사 코드 결정
|
||||
// copyData.targetCompanyCode가 있으면 사용 (회사 간 복사)
|
||||
// 없으면 원본과 같은 회사에 복사
|
||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||
|
||||
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
||||
const existingScreens = await client.query<any>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[copyData.screenCode, copyData.companyCode]
|
||||
[copyData.screenCode, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreens.rows.length > 0) {
|
||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 3. 새 화면 생성
|
||||
// 4. 새 화면 생성 (대상 회사에 생성)
|
||||
const newScreenResult = await client.query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_code, screen_name, description, company_code, table_name,
|
||||
@@ -1920,12 +2100,12 @@ export class ScreenManagementService {
|
||||
copyData.screenCode,
|
||||
copyData.screenName,
|
||||
copyData.description || sourceScreen.description,
|
||||
copyData.companyCode,
|
||||
targetCompanyCode, // 대상 회사 코드 사용
|
||||
sourceScreen.table_name,
|
||||
sourceScreen.is_active,
|
||||
copyData.createdBy,
|
||||
copyData.userId,
|
||||
new Date(),
|
||||
copyData.createdBy,
|
||||
copyData.userId,
|
||||
new Date(),
|
||||
]
|
||||
);
|
||||
@@ -2005,6 +2185,165 @@ export class ScreenManagementService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 화면 + 연결된 모달 화면들 일괄 복사
|
||||
*/
|
||||
async copyScreenWithModals(data: {
|
||||
sourceScreenId: number;
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
|
||||
mainScreen: {
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
description?: string;
|
||||
};
|
||||
modalScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
}): Promise<{
|
||||
mainScreen: ScreenDefinition;
|
||||
modalScreens: ScreenDefinition[];
|
||||
}> {
|
||||
const targetCompany = data.targetCompanyCode || data.companyCode;
|
||||
console.log(`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`);
|
||||
|
||||
// 1. 메인 화면 복사
|
||||
const mainScreen = await this.copyScreen(data.sourceScreenId, {
|
||||
screenName: data.mainScreen.screenName,
|
||||
screenCode: data.mainScreen.screenCode,
|
||||
description: data.mainScreen.description || "",
|
||||
companyCode: data.companyCode,
|
||||
userId: data.userId,
|
||||
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
|
||||
console.log(`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`);
|
||||
|
||||
// 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑)
|
||||
const modalScreens: ScreenDefinition[] = [];
|
||||
const screenIdMapping: Map<number, number> = new Map(); // 원본 ID → 새 ID
|
||||
|
||||
for (const modalData of data.modalScreens) {
|
||||
const copiedModal = await this.copyScreen(modalData.sourceScreenId, {
|
||||
screenName: modalData.screenName,
|
||||
screenCode: modalData.screenCode,
|
||||
description: "",
|
||||
companyCode: data.companyCode,
|
||||
userId: data.userId,
|
||||
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
|
||||
modalScreens.push(copiedModal);
|
||||
screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId);
|
||||
|
||||
console.log(
|
||||
`✅ 모달 화면 복사 완료: ${modalData.sourceScreenId} → ${copiedModal.screenId} (${copiedModal.screenCode})`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트
|
||||
// 모든 복사가 완료되고 커밋된 후에 실행
|
||||
console.log(`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`,
|
||||
Array.from(screenIdMapping.entries())
|
||||
);
|
||||
|
||||
const updateCount = await this.updateButtonTargetScreenIds(
|
||||
mainScreen.screenId,
|
||||
screenIdMapping
|
||||
);
|
||||
|
||||
console.log(`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`);
|
||||
|
||||
return {
|
||||
mainScreen,
|
||||
modalScreens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 레이아웃에서 버튼의 targetScreenId를 새 screenId로 업데이트
|
||||
* (독립적인 트랜잭션으로 실행)
|
||||
*/
|
||||
private async updateButtonTargetScreenIds(
|
||||
screenId: number,
|
||||
screenIdMapping: Map<number, number>
|
||||
): Promise<number> {
|
||||
console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`);
|
||||
|
||||
// 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`);
|
||||
|
||||
let updateCount = 0;
|
||||
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
if (
|
||||
properties?.componentType === "button" ||
|
||||
properties?.componentType?.startsWith("button-")
|
||||
) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
// targetScreenId가 있는 액션 (popup, modal, edit)
|
||||
if (
|
||||
(action?.type === "popup" ||
|
||||
action?.type === "modal" ||
|
||||
action?.type === "edit") &&
|
||||
action?.targetScreenId
|
||||
) {
|
||||
const oldScreenId = parseInt(action.targetScreenId);
|
||||
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||
|
||||
// 매핑에 있으면 업데이트
|
||||
if (screenIdMapping.has(oldScreenId)) {
|
||||
const newScreenId = screenIdMapping.get(oldScreenId)!;
|
||||
console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`);
|
||||
|
||||
// properties 업데이트
|
||||
properties.componentConfig.action.targetScreenId =
|
||||
newScreenId.toString();
|
||||
|
||||
// 데이터베이스 업데이트
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1
|
||||
WHERE layout_id = $2`,
|
||||
[JSON.stringify(properties), layout.layout_id]
|
||||
);
|
||||
|
||||
updateCount++;
|
||||
console.log(
|
||||
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
|
||||
// 개별 레이아웃 오류는 무시하고 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
|
||||
return updateCount;
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 인스턴스 export
|
||||
|
||||
@@ -640,6 +640,339 @@ class TableCategoryValueService {
|
||||
children: this.buildHierarchy(values, v.valueId!),
|
||||
}));
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 메서드 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 조회
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param menuObjid - 메뉴 OBJID
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { logical_column: physical_column } 형태의 매핑 객체
|
||||
*/
|
||||
async getColumnMapping(
|
||||
tableName: string,
|
||||
menuObjid: number,
|
||||
companyCode: string
|
||||
): Promise<Record<string, string>> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode });
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 매핑 조회 가능
|
||||
query = `
|
||||
SELECT
|
||||
logical_column_name AS "logicalColumnName",
|
||||
physical_column_name AS "physicalColumnName"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND menu_objid = $2
|
||||
`;
|
||||
params = [tableName, menuObjid];
|
||||
} else {
|
||||
// 일반 회사: 자신의 매핑만 조회
|
||||
query = `
|
||||
SELECT
|
||||
logical_column_name AS "logicalColumnName",
|
||||
physical_column_name AS "physicalColumnName"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND menu_objid = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
params = [tableName, menuObjid, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { logical_column: physical_column } 형태로 변환
|
||||
const mapping: Record<string, string> = {};
|
||||
result.rows.forEach((row: any) => {
|
||||
mapping[row.logicalColumnName] = row.physicalColumnName;
|
||||
});
|
||||
|
||||
logger.info(`컬럼 매핑 ${Object.keys(mapping).length}개 조회 완료`, {
|
||||
tableName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
return mapping;
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 생성/수정
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param logicalColumnName - 논리적 컬럼명
|
||||
* @param physicalColumnName - 물리적 컬럼명
|
||||
* @param menuObjid - 메뉴 OBJID
|
||||
* @param companyCode - 회사 코드
|
||||
* @param userId - 사용자 ID
|
||||
* @param description - 설명 (선택사항)
|
||||
*/
|
||||
async createColumnMapping(
|
||||
tableName: string,
|
||||
logicalColumnName: string,
|
||||
physicalColumnName: string,
|
||||
menuObjid: number,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
description?: string
|
||||
): Promise<any> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("컬럼 매핑 생성", {
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 1. 물리적 컬럼이 실제로 존재하는지 확인
|
||||
const columnCheckQuery = `
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
const columnCheck = await pool.query(columnCheckQuery, [
|
||||
tableName,
|
||||
physicalColumnName,
|
||||
]);
|
||||
|
||||
if (columnCheck.rowCount === 0) {
|
||||
throw new Error(
|
||||
`테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 매핑 저장 (UPSERT)
|
||||
const insertQuery = `
|
||||
INSERT INTO category_column_mapping (
|
||||
table_name,
|
||||
logical_column_name,
|
||||
physical_column_name,
|
||||
menu_objid,
|
||||
company_code,
|
||||
description,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||
DO UPDATE SET
|
||||
physical_column_name = EXCLUDED.physical_column_name,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(insertQuery, [
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
description || null,
|
||||
userId,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("컬럼 매핑 생성 완료", {
|
||||
mappingId: result.rows[0].mapping_id,
|
||||
tableName,
|
||||
logicalColumnName,
|
||||
physicalColumnName,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리적 컬럼 목록 조회
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param menuObjid - 메뉴 OBJID
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns 논리적 컬럼 목록
|
||||
*/
|
||||
async getLogicalColumns(
|
||||
tableName: string,
|
||||
menuObjid: number,
|
||||
companyCode: string
|
||||
): Promise<any[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("논리적 컬럼 목록 조회", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 논리적 컬럼 조회
|
||||
query = `
|
||||
SELECT
|
||||
mapping_id AS "mappingId",
|
||||
logical_column_name AS "logicalColumnName",
|
||||
physical_column_name AS "physicalColumnName",
|
||||
description
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND menu_objid = $2
|
||||
ORDER BY logical_column_name
|
||||
`;
|
||||
params = [tableName, menuObjid];
|
||||
} else {
|
||||
// 일반 회사: 자신의 논리적 컬럼만 조회
|
||||
query = `
|
||||
SELECT
|
||||
mapping_id AS "mappingId",
|
||||
logical_column_name AS "logicalColumnName",
|
||||
physical_column_name AS "physicalColumnName",
|
||||
description
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND menu_objid = $2
|
||||
AND company_code = $3
|
||||
ORDER BY logical_column_name
|
||||
`;
|
||||
params = [tableName, menuObjid, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info(`논리적 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||
tableName,
|
||||
menuObjid,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑 삭제
|
||||
*
|
||||
* @param mappingId - 매핑 ID
|
||||
* @param companyCode - 회사 코드
|
||||
*/
|
||||
async deleteColumnMapping(
|
||||
mappingId: number,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("컬럼 매핑 삭제", { mappingId, companyCode });
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let deleteQuery: string;
|
||||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 매핑 삭제 가능
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE mapping_id = $1
|
||||
`;
|
||||
deleteParams = [mappingId];
|
||||
} else {
|
||||
// 일반 회사: 자신의 매핑만 삭제 가능
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE mapping_id = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
deleteParams = [mappingId, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(deleteQuery, deleteParams);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
|
||||
logger.info("컬럼 매핑 삭제 완료", { mappingId, companyCode });
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리적 컬럼명을 물리적 컬럼명으로 변환
|
||||
*
|
||||
* 데이터 저장 시 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param menuObjid - 메뉴 OBJID
|
||||
* @param companyCode - 회사 코드
|
||||
* @param data - 논리적 컬럼명으로 된 데이터
|
||||
* @returns 물리적 컬럼명으로 변환된 데이터
|
||||
*/
|
||||
async convertToPhysicalColumns(
|
||||
tableName: string,
|
||||
menuObjid: number,
|
||||
companyCode: string,
|
||||
data: Record<string, any>
|
||||
): Promise<Record<string, any>> {
|
||||
try {
|
||||
// 컬럼 매핑 조회
|
||||
const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode);
|
||||
|
||||
// 논리적 컬럼명 → 물리적 컬럼명 변환
|
||||
const physicalData: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
|
||||
physicalData[physicalColumn] = value;
|
||||
}
|
||||
|
||||
logger.info("컬럼명 변환 완료", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
logicalColumns: Object.keys(data),
|
||||
physicalColumns: Object.keys(physicalData),
|
||||
});
|
||||
|
||||
return physicalData;
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼명 변환 실패: ${error.message}`);
|
||||
// 매핑이 없으면 원본 데이터 그대로 반환
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
||||
Reference in New Issue
Block a user