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:
kjs
2025-11-13 12:17:10 +09:00
parent b77fffbad7
commit 658211b9d1
21 changed files with 4969 additions and 209 deletions

View File

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

View File

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

View File

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

View File

@@ -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); // 회사 삭제

View File

@@ -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); // 특정 테이블 정보 조회 (최적화)

View File

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

View File

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

View File

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

View File

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