ERP-node/backend-node/src/services/screenManagementService.ts

2900 lines
91 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ✅ Prisma → Raw Query 전환 (Phase 2.1)
import { query, queryOne, transaction } from "../database/db";
import {
ScreenDefinition,
CreateScreenRequest,
UpdateScreenRequest,
LayoutData,
SaveLayoutRequest,
ScreenTemplate,
MenuAssignmentRequest,
PaginatedResponse,
ComponentData,
ColumnInfo,
ColumnWebTypeSetting,
WebType,
WidgetData,
} from "../types/screen";
import { generateId } from "../utils/generateId";
import logger from "../utils/logger";
// 화면 복사 요청 인터페이스
interface CopyScreenRequest {
screenName: string;
screenCode: string;
description?: string;
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
createdBy?: string; // 생성자 ID
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
}
// 백엔드에서 사용할 테이블 정보 타입
interface TableInfo {
tableName: string;
tableLabel: string;
columns: ColumnInfo[];
}
export class ScreenManagementService {
// ========================================
// 화면 정의 관리
// ========================================
/**
* 화면 정의 생성 (✅ Raw Query 전환 완료)
*/
async createScreen(
screenData: CreateScreenRequest,
userCompanyCode: string
): Promise<ScreenDefinition> {
console.log(`=== 화면 생성 요청 ===`);
console.log(`요청 데이터:`, screenData);
console.log(`사용자 회사 코드:`, userCompanyCode);
// 화면 코드 중복 확인 (Raw Query)
const existingResult = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND is_active != 'D'
LIMIT 1`,
[screenData.screenCode]
);
console.log(
`화면 코드 '${screenData.screenCode}' 중복 검사 결과:`,
existingResult.length > 0 ? "중복됨" : "사용 가능"
);
if (existingResult.length > 0) {
console.log(`기존 화면 정보:`, existingResult[0]);
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 화면 생성 (Raw Query) - REST API 지원 추가
const [screen] = await query<any>(
`INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, description, created_by,
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
rest_api_endpoint, rest_api_json_path
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
screenData.screenName,
screenData.screenCode,
screenData.tableName,
screenData.companyCode,
screenData.description || null,
screenData.createdBy,
screenData.dbSourceType || "internal",
screenData.dbConnectionId || null,
(screenData as any).dataSourceType || "database",
(screenData as any).restApiConnectionId || null,
(screenData as any).restApiEndpoint || null,
(screenData as any).restApiJsonPath || "data",
]
);
return this.mapToScreenDefinition(screen);
}
/**
* 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만 (✅ Raw Query 전환 완료)
*/
async getScreensByCompany(
companyCode: string,
page: number = 1,
size: number = 20,
searchTerm?: string // 검색어 추가
): Promise<PaginatedResponse<ScreenDefinition>> {
const offset = (page - 1) * size;
// WHERE 절 동적 생성
const whereConditions: string[] = ["is_active != 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
if (searchTerm && searchTerm.trim() !== "") {
whereConditions.push(`(
screen_name ILIKE $${params.length + 1} OR
screen_code ILIKE $${params.length + 1} OR
table_name ILIKE $${params.length + 1}
)`);
params.push(`%${searchTerm.trim()}%`);
}
const whereSQL = whereConditions.join(" AND ");
// 페이징 쿼리 (Raw Query)
const [screens, totalResult] = await Promise.all([
query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY created_date DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset]
),
query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions
WHERE ${whereSQL}`,
params
),
]);
const total = parseInt(totalResult[0]?.count || "0", 10);
// 테이블 라벨 정보를 한 번에 조회 (Raw Query)
const tableNames = Array.from(
new Set(screens.map((s: any) => s.table_name).filter(Boolean))
);
let tableLabelMap = new Map<string, string>();
if (tableNames.length > 0) {
try {
const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", ");
const tableLabels = await query<{
table_name: string;
table_label: string | null;
}>(
`SELECT table_name, table_label FROM table_labels
WHERE table_name IN (${placeholders})`,
tableNames
);
tableLabelMap = new Map(
tableLabels.map((tl) => [
tl.table_name,
tl.table_label || tl.table_name,
])
);
// 테스트: company_mng 라벨 직접 확인
if (tableLabelMap.has("company_mng")) {
console.log(
"✅ company_mng 라벨 찾음:",
tableLabelMap.get("company_mng")
);
} else {
console.log("❌ company_mng 라벨 없음");
}
} catch (error) {
console.error("테이블 라벨 조회 오류:", error);
}
}
return {
data: screens.map((screen) =>
this.mapToScreenDefinition(screen, tableLabelMap)
),
pagination: {
page,
size,
total,
totalPages: Math.ceil(total / size),
},
};
}
/**
* 화면 목록 조회 (간단 버전) - 활성 화면만 (✅ Raw Query 전환 완료)
*/
async getScreens(companyCode: string): Promise<ScreenDefinition[]> {
// 동적 WHERE 절 생성
const whereConditions: string[] = ["is_active != 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY created_date DESC`,
params
);
return screens.map((screen) => this.mapToScreenDefinition(screen));
}
/**
* 화면 정의 조회 (활성 화면만) (✅ Raw Query 전환 완료)
*/
async getScreenById(screenId: number): Promise<ScreenDefinition | null> {
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE screen_id = $1 AND is_active != 'D'
LIMIT 1`,
[screenId]
);
return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null;
}
/**
* 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만) (✅ Raw Query 전환 완료)
*/
async getScreen(
screenId: number,
companyCode: string
): Promise<ScreenDefinition | null> {
// 동적 WHERE 절 생성
const whereConditions: string[] = [
"screen_id = $1",
"is_active != 'D'", // 삭제된 화면 제외
];
const params: any[] = [screenId];
// 회사 코드가 '*'가 아닌 경우 회사별 필터링
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screens = await query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
LIMIT 1`,
params
);
return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null;
}
/**
* 화면 정의 수정 (✅ Raw Query 전환 완료)
*/
async updateScreen(
screenId: number,
updateData: UpdateScreenRequest,
userCompanyCode: string
): Promise<ScreenDefinition> {
// 권한 확인 (Raw Query)
const existingResult = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 수정할 권한이 없습니다.");
}
// 화면 업데이트 (Raw Query)
const [screen] = await query<any>(
`UPDATE screen_definitions
SET screen_name = $1,
description = $2,
is_active = $3,
updated_by = $4,
updated_date = $5
WHERE screen_id = $6
RETURNING *`,
[
updateData.screenName,
updateData.description || null,
updateData.isActive ? "Y" : "N",
updateData.updatedBy,
new Date(),
screenId,
]
);
return this.mapToScreenDefinition(screen);
}
/**
* 화면 정보 수정 (메타데이터만) - 편집 기능용
*/
async updateScreenInfo(
screenId: number,
updateData: {
screenName: string;
tableName?: string;
description?: string;
isActive: string;
// REST API 관련 필드 추가
dataSourceType?: string;
dbSourceType?: string;
dbConnectionId?: number;
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
},
userCompanyCode: string
): Promise<void> {
// 권한 확인
const existingResult = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 수정할 권한이 없습니다.");
}
// 화면 정보 업데이트 (REST API 필드 포함)
await query(
`UPDATE screen_definitions
SET screen_name = $1,
table_name = $2,
description = $3,
is_active = $4,
updated_date = $5,
data_source_type = $6,
db_source_type = $7,
db_connection_id = $8,
rest_api_connection_id = $9,
rest_api_endpoint = $10,
rest_api_json_path = $11
WHERE screen_id = $12`,
[
updateData.screenName,
updateData.tableName || null,
updateData.description || null,
updateData.isActive,
new Date(),
updateData.dataSourceType || "database",
updateData.dbSourceType || "internal",
updateData.dbConnectionId || null,
updateData.restApiConnectionId || null,
updateData.restApiEndpoint || null,
updateData.restApiJsonPath || null,
screenId,
]
);
console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, {
dataSourceType: updateData.dataSourceType,
restApiConnectionId: updateData.restApiConnectionId,
restApiEndpoint: updateData.restApiEndpoint,
restApiJsonPath: updateData.restApiJsonPath,
});
}
/**
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
*/
async checkScreenDependencies(
screenId: number,
userCompanyCode: string
): Promise<{
hasDependencies: boolean;
dependencies: Array<{
screenId: number;
screenName: string;
screenCode: string;
componentId: string;
componentType: string;
referenceType: string; // 'popup', 'navigate', 'targetScreen' 등
}>;
}> {
// 권한 확인
const targetScreens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (targetScreens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const targetScreen = targetScreens[0];
if (
userCompanyCode !== "*" &&
targetScreen.company_code !== "*" &&
targetScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면에 접근할 권한이 없습니다.");
}
// 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인
const whereConditions: string[] = ["sd.is_active != 'D'"];
const params: any[] = [];
if (userCompanyCode !== "*") {
whereConditions.push(
`sd.company_code IN ($${params.length + 1}, $${params.length + 2})`
);
params.push(userCompanyCode, "*");
}
const whereSQL = whereConditions.join(" AND ");
// 화면과 레이아웃을 JOIN해서 조회
const allScreens = await query<any>(
`SELECT
sd.screen_id, sd.screen_name, sd.screen_code, sd.company_code,
sl.layout_id, sl.component_id, sl.component_type, sl.properties
FROM screen_definitions sd
LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE ${whereSQL}
ORDER BY sd.screen_id, sl.layout_id`,
params
);
const dependencies: Array<{
screenId: number;
screenName: string;
screenCode: string;
componentId: string;
componentType: string;
referenceType: string;
}> = [];
// 각 화면의 레이아웃에서 버튼 컴포넌트들을 검사
for (const screen of allScreens) {
if (screen.screen_id === screenId) continue; // 자기 자신은 제외
try {
// screen_layouts 테이블에서 버튼 컴포넌트 확인 (위젯 타입만)
if (screen.component_type === "widget") {
const properties = screen.properties as any;
// 버튼 컴포넌트인지 확인
if (properties?.widgetType === "button") {
const config = properties.webTypeConfig;
if (!config) continue;
// popup 액션에서 popupScreenId 확인
if (
config.actionType === "popup" &&
config.popupScreenId === screenId
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "popup",
});
}
// navigate 액션에서 navigateScreenId 확인
if (
config.actionType === "navigate" &&
config.navigateScreenId === screenId
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "navigate",
});
}
// navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123)
if (
config.navigateUrl &&
config.navigateUrl.includes(`/screens/${screenId}`)
) {
dependencies.push({
screenId: screen.screen_id,
screenName: screen.screen_name,
screenCode: screen.screen_code,
componentId: screen.component_id,
componentType: "button",
referenceType: "url",
});
}
}
}
// 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음
// 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함
} catch (error) {
console.error(
`화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`,
error
);
continue;
}
}
// 메뉴 할당 확인 (Raw Query)
try {
const menuAssignments = await query<{
assignment_id: number;
menu_objid: number;
menu_name_kor?: string;
}>(
`SELECT sma.assignment_id, sma.menu_objid, mi.menu_name_kor
FROM screen_menu_assignments sma
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
WHERE sma.screen_id = $1 AND sma.is_active = 'Y'`,
[screenId]
);
// 메뉴에 할당된 경우 의존성에 추가
for (const assignment of menuAssignments) {
dependencies.push({
screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정
screenName: assignment.menu_name_kor || "알 수 없는 메뉴",
screenCode: `MENU_${assignment.menu_objid}`,
componentId: `menu_${assignment.assignment_id}`,
componentType: "menu",
referenceType: "menu_assignment",
});
}
} catch (error) {
console.error("메뉴 할당 확인 중 오류:", error);
// 메뉴 할당 확인 실패해도 다른 의존성 체크는 계속 진행
}
return {
hasDependencies: dependencies.length > 0,
dependencies,
};
}
/**
* 화면 정의 삭제 (휴지통으로 이동 - 소프트 삭제) (✅ Raw Query 전환 완료)
*/
async deleteScreen(
screenId: number,
userCompanyCode: string,
deletedBy: string,
deleteReason?: string,
force: boolean = false
): Promise<void> {
// 권한 확인 (Raw Query)
const existingResult = await query<{
company_code: string | null;
is_active: string;
}>(
`SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (existingResult.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = existingResult[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 삭제할 권한이 없습니다.");
}
// 이미 삭제된 화면인지 확인
if (existingScreen.is_active === "D") {
throw new Error("이미 삭제된 화면입니다.");
}
// 강제 삭제가 아닌 경우 의존성 체크
if (!force) {
const dependencyCheck = await this.checkScreenDependencies(
screenId,
userCompanyCode
);
if (dependencyCheck.hasDependencies) {
const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any;
error.code = "SCREEN_HAS_DEPENDENCIES";
error.dependencies = dependencyCheck.dependencies;
throw error;
}
}
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query)
await transaction(async (client) => {
// 소프트 삭제 (휴지통으로 이동)
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`,
[
new Date(),
deletedBy,
deleteReason || null,
new Date(),
deletedBy,
screenId,
]
);
// 메뉴 할당도 비활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
WHERE screen_id = $1 AND is_active = 'Y'`,
[screenId]
);
});
}
/**
* 화면 복원 (휴지통에서 복원) (✅ Raw Query 전환 완료)
*/
async restoreScreen(
screenId: number,
userCompanyCode: string,
restoredBy: string
): Promise<void> {
// 권한 확인
const screens = await query<{
company_code: string | null;
is_active: string;
screen_code: string;
}>(
`SELECT company_code, is_active, screen_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 복원할 권한이 없습니다.");
}
// 삭제된 화면이 아닌 경우
if (existingScreen.is_active !== "D") {
throw new Error("삭제된 화면이 아닙니다.");
}
// 화면 코드 중복 확인 (복원 시 같은 코드가 이미 존재하는지)
const duplicateScreens = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND is_active != 'D' AND screen_id != $2
LIMIT 1`,
[existingScreen.screen_code, screenId]
);
if (duplicateScreens.length > 0) {
throw new Error(
"같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요."
);
}
// 트랜잭션으로 화면 복원과 메뉴 할당 복원을 함께 처리
await transaction(async (client) => {
// 화면 복원
await client.query(
`UPDATE screen_definitions
SET is_active = 'Y', deleted_date = NULL, deleted_by = NULL,
delete_reason = NULL, updated_date = $1, updated_by = $2
WHERE screen_id = $3`,
[new Date(), restoredBy, screenId]
);
// 메뉴 할당도 다시 활성화
await client.query(
`UPDATE screen_menu_assignments
SET is_active = 'Y'
WHERE screen_id = $1 AND is_active = 'N'`,
[screenId]
);
});
}
/**
* 휴지통 화면들의 메뉴 할당 정리 (관리자용) (✅ Raw Query 전환 완료)
*/
async cleanupDeletedScreenMenuAssignments(): Promise<{
updatedCount: number;
message: string;
}> {
const result = await query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
WHERE screen_id IN (
SELECT screen_id
FROM screen_definitions
WHERE is_active = 'D'
) AND is_active = 'Y'`,
[]
);
const updatedCount = result.length;
return {
updatedCount,
message: `${updatedCount}개의 메뉴 할당이 정리되었습니다.`,
};
}
/**
* 화면 영구 삭제 (휴지통에서 완전 삭제) (✅ Raw Query 전환 완료)
*/
async permanentDeleteScreen(
screenId: number,
userCompanyCode: string
): Promise<void> {
// 권한 확인
const screens = await query<{
company_code: string | null;
is_active: string;
}>(
`SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (
userCompanyCode !== "*" &&
existingScreen.company_code !== userCompanyCode
) {
throw new Error("이 화면을 영구 삭제할 권한이 없습니다.");
}
// 삭제된 화면이 아닌 경우 영구 삭제 불가
if (existingScreen.is_active !== "D") {
throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다.");
}
// 물리적 삭제 (수동으로 관련 데이터 삭제)
await transaction(async (client) => {
await client.query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [
screenId,
]);
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId]
);
await client.query(
`DELETE FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
});
}
/**
* 휴지통 화면 목록 조회 (✅ Raw Query 전환 완료)
*/
async getDeletedScreens(
companyCode: string,
page: number = 1,
size: number = 20
): Promise<
PaginatedResponse<
ScreenDefinition & {
deletedDate?: Date;
deletedBy?: string;
deleteReason?: string;
}
>
> {
const offset = (page - 1) * size;
const whereConditions: string[] = ["is_active = 'D'"];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
const whereSQL = whereConditions.join(" AND ");
const [screens, totalResult] = await Promise.all([
query<any>(
`SELECT * FROM screen_definitions
WHERE ${whereSQL}
ORDER BY deleted_date DESC NULLS LAST
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, size, offset]
),
query<{ count: string }>(
`SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`,
params
),
]);
const total = parseInt(totalResult[0]?.count || "0", 10);
// 테이블 라벨 정보를 한 번에 조회
const tableNames = Array.from(
new Set(screens.map((s: any) => s.table_name).filter(Boolean))
);
let tableLabelMap = new Map<string, string>();
if (tableNames.length > 0) {
const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", ");
const tableLabels = await query<{
table_name: string;
table_label: string | null;
}>(
`SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`,
tableNames
);
tableLabelMap = new Map(
tableLabels.map((tl: any) => [
tl.table_name,
tl.table_label || tl.table_name,
])
);
}
return {
data: screens.map((screen: any) => ({
...this.mapToScreenDefinition(screen, tableLabelMap),
deletedDate: screen.deleted_date || undefined,
deletedBy: screen.deleted_by || undefined,
deleteReason: screen.delete_reason || undefined,
})),
pagination: {
page,
size,
total,
totalPages: Math.ceil(total / size),
},
};
}
/**
* 활성 화면 일괄 삭제 (휴지통으로 이동)
*/
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 };
}
/**
* 휴지통 화면 일괄 영구 삭제
*/
async bulkPermanentDeleteScreens(
screenIds: number[],
userCompanyCode: string
): Promise<{
deletedCount: number;
skippedCount: number;
errors: Array<{ screenId: number; error: string }>;
}> {
if (screenIds.length === 0) {
throw new Error("삭제할 화면을 선택해주세요.");
}
// 권한 확인 - 해당 회사의 휴지통 화면들만 조회
const whereClause: any = {
screen_id: { in: screenIds },
is_active: "D", // 휴지통에 있는 화면만
};
if (userCompanyCode !== "*") {
whereClause.company_code = userCompanyCode;
}
// WHERE 절 생성
const whereConditions: string[] = ["is_active = 'D'"];
const params: any[] = [];
if (userCompanyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(userCompanyCode);
}
const whereSQL = whereConditions.join(" AND ");
const screensToDelete = await query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions WHERE ${whereSQL}`,
params
);
let deletedCount = 0;
let skippedCount = 0;
const errors: Array<{ screenId: number; error: string }> = [];
// 각 화면을 개별적으로 삭제 처리
for (const screenId of screenIds) {
try {
const screenToDelete = screensToDelete.find(
(s: any) => s.screen_id === screenId
);
if (!screenToDelete) {
skippedCount++;
errors.push({
screenId,
error: "화면을 찾을 수 없거나 삭제 권한이 없습니다.",
});
continue;
}
// 관련 레이아웃 데이터도 함께 삭제 (트랜잭션)
await transaction(async (client) => {
// screen_layouts 삭제
await client.query(
`DELETE FROM screen_layouts WHERE screen_id = $1`,
[screenId]
);
// screen_menu_assignments 삭제
await client.query(
`DELETE FROM screen_menu_assignments WHERE screen_id = $1`,
[screenId]
);
// screen_definitions 삭제
await client.query(
`DELETE FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
});
deletedCount++;
} catch (error) {
skippedCount++;
errors.push({
screenId,
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
console.error(`화면 ${screenId} 영구 삭제 실패:`, error);
}
}
return {
deletedCount,
skippedCount,
errors,
};
}
// ========================================
// 테이블 관리
// ========================================
/**
* 테이블 목록 조회 (모든 테이블) (✅ Raw Query 전환 완료)
*/
async getTables(companyCode: string): Promise<TableInfo[]> {
try {
// PostgreSQL에서 사용 가능한 테이블 목록 조회
const tables = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[]
);
// 각 테이블의 컬럼 정보도 함께 조회
const tableInfos: TableInfo[] = [];
for (const table of tables) {
const columns = await this.getTableColumns(
table.table_name,
companyCode
);
if (columns.length > 0) {
tableInfos.push({
tableName: table.table_name,
tableLabel: this.getTableLabel(table.table_name),
columns: columns,
});
}
}
return tableInfos;
} catch (error) {
console.error("테이블 목록 조회 실패:", error);
throw new Error("테이블 목록을 조회할 수 없습니다.");
}
}
/**
* 특정 테이블 정보 조회 (최적화된 단일 테이블 조회)
*/
async getTableInfo(
tableName: string,
companyCode: string
): Promise<TableInfo | null> {
try {
console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`);
// 테이블 존재 여부 확인
const tableExists = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name = $1`,
[tableName]
);
if (tableExists.length === 0) {
console.log(`테이블 ${tableName}이 존재하지 않습니다.`);
return null;
}
// 해당 테이블의 컬럼 정보 조회
const columns = await this.getTableColumns(tableName, companyCode);
if (columns.length === 0) {
console.log(`테이블 ${tableName}의 컬럼 정보가 없습니다.`);
return null;
}
const tableInfo: TableInfo = {
tableName: tableName,
tableLabel: this.getTableLabel(tableName),
columns: columns,
};
console.log(
`단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}`
);
return tableInfo;
} catch (error) {
console.error(`테이블 ${tableName} 조회 실패:`, error);
throw new Error(`테이블 ${tableName} 정보를 조회할 수 없습니다.`);
}
}
/**
* 테이블 컬럼 정보 조회 (✅ Raw Query 전환 완료)
*/
async getTableColumns(
tableName: string,
companyCode: string
): Promise<ColumnInfo[]> {
try {
// 테이블 컬럼 정보 조회
const columns = await query<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
character_maximum_length: number | null;
numeric_precision: number | null;
numeric_scale: number | null;
}>(
`SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position`,
[tableName]
);
// 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음)
// 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리
console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`);
const typeInfo = await query<{
column_name: string;
input_type: string | null;
detail_settings: any;
}>(
`SELECT column_name, input_type, detail_settings
FROM table_type_columns
WHERE table_name = $1
AND company_code = $2
ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지)
[tableName, companyCode]
);
console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}`);
const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code');
if (currencyCodeType) {
console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType);
} else {
console.log(`⚠️ [getTableColumns] currency_code 없음`);
}
// column_labels 테이블에서 라벨 정보 조회 (우선순위 2)
const labelInfo = await query<{
column_name: string;
column_label: string | null;
}>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
// 🆕 category_column_mapping에서 코드 카테고리 정보 조회
const categoryInfo = await query<{
physical_column_name: string;
logical_column_name: string;
}>(
`SELECT physical_column_name, logical_column_name
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
// 컬럼 정보 매핑
const columnMap = new Map<string, any>();
// 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성
columns.forEach((column: any) => {
columnMap.set(column.column_name, {
tableName: tableName,
columnName: column.column_name,
dataType: column.data_type,
isNullable: column.is_nullable,
columnDefault: column.column_default || undefined,
characterMaximumLength: column.character_maximum_length || undefined,
numericPrecision: column.numeric_precision || undefined,
numericScale: column.numeric_scale || undefined,
});
});
console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}`);
// table_type_columns에서 input_type 추가 (중복 시 최신 것만)
const addedTypes = new Set<string>();
typeInfo.forEach((type) => {
const colName = type.column_name;
if (!addedTypes.has(colName) && columnMap.has(colName)) {
const col = columnMap.get(colName);
col.inputType = type.input_type;
col.webType = type.input_type; // webType도 동일하게 설정
col.detailSettings = type.detail_settings;
addedTypes.add(colName);
if (colName === 'currency_code') {
console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`);
}
}
});
console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}`);
// column_labels에서 라벨 추가
labelInfo.forEach((label) => {
const col = columnMap.get(label.column_name);
if (col) {
col.columnLabel = label.column_label || this.getColumnLabel(label.column_name);
}
});
// category_column_mapping에서 코드 카테고리 추가
categoryInfo.forEach((cat) => {
const col = columnMap.get(cat.physical_column_name);
if (col) {
col.codeCategory = cat.logical_column_name;
}
});
// 최종 결과 생성
const result = Array.from(columnMap.values()).map((col) => ({
...col,
// 기본값 설정
columnLabel: col.columnLabel || this.getColumnLabel(col.columnName),
inputType: col.inputType || this.inferWebType(col.dataType),
webType: col.webType || this.inferWebType(col.dataType),
detailSettings: col.detailSettings || undefined,
codeCategory: col.codeCategory || undefined,
}));
// 디버깅: currency_code의 최종 inputType 확인
const currencyCodeResult = result.find(r => r.columnName === 'currency_code');
if (currencyCodeResult) {
console.log(`🎯 [getTableColumns] 최종 currency_code:`, {
inputType: currencyCodeResult.inputType,
webType: currencyCodeResult.webType,
dataType: currencyCodeResult.dataType
});
}
console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`);
return result;
} catch (error) {
console.error("테이블 컬럼 조회 실패:", error);
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
}
}
/**
* 테이블 라벨 생성
*/
private getTableLabel(tableName: string): string {
// snake_case를 읽기 쉬운 형태로 변환
return tableName
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace(/\s+/g, " ")
.trim();
}
/**
* 컬럼 라벨 생성
*/
private getColumnLabel(columnName: string): string {
// snake_case를 읽기 쉬운 형태로 변환
return columnName
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace(/\s+/g, " ")
.trim();
}
/**
* 데이터 타입으로부터 웹타입 추론
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================
// 레이아웃 관리
// ========================================
/**
* 레이아웃 저장 (✅ Raw Query 전환 완료)
*/
async saveLayout(
screenId: number,
layoutData: LayoutData,
companyCode: string
): Promise<void> {
console.log(`=== 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}`);
console.log(`컴포넌트 수: ${layoutData.components.length}`);
console.log(`격자 설정:`, layoutData.gridSettings);
console.log(`해상도 설정:`, layoutData.screenResolution);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두)
await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);
// 1. 메타데이터 저장 (격자 설정과 해상도 정보)
if (layoutData.gridSettings || layoutData.screenResolution) {
const metadata: any = {
gridSettings: layoutData.gridSettings,
screenResolution: layoutData.screenResolution,
};
await query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties, display_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
screenId,
"_metadata", // 특별한 타입으로 메타데이터 식별
`_metadata_${screenId}`,
null,
0,
0,
0,
0,
JSON.stringify(metadata),
-1, // 메타데이터는 맨 앞에 배치
]
);
console.log(`메타데이터 저장 완료:`, metadata);
}
// 2. 컴포넌트 저장
for (const component of layoutData.components) {
const { id, ...componentData } = component;
console.log(`저장 중인 컴포넌트:`, {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
parentId: component.parentId,
title: (component as any).title,
});
// JSON 필드에 맞는 타입으로 변환
const properties: any = {
...componentData,
position: {
x: component.position.x,
y: component.position.y,
z: component.position.z || 1, // z 값 포함
},
size: {
width: component.size.width,
height: component.size.height,
},
};
// 🔍 디버깅: webTypeConfig.dataflowConfig 확인
if ((component as any).webTypeConfig?.dataflowConfig) {
console.log(`🔍 컴포넌트 ${component.id}의 dataflowConfig:`, JSON.stringify((component as any).webTypeConfig.dataflowConfig, null, 2));
}
await query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
screenId,
component.type,
component.id,
component.parentId || null,
Math.round(component.position.x), // 정수로 반올림
Math.round(component.position.y), // 정수로 반올림
Math.round(component.size.width), // 정수로 반올림
Math.round(component.size.height), // 정수로 반올림
JSON.stringify(properties),
]
);
}
console.log(`=== 레이아웃 저장 완료 ===`);
}
/**
* 레이아웃 조회 (✅ Raw Query 전환 완료)
*/
async getLayout(
screenId: number,
companyCode: string
): Promise<LayoutData | null> {
console.log(`=== 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}`);
// 권한 확인 및 테이블명 조회
const screens = await query<{ company_code: string | null; table_name: string | null }>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
const layouts = await query<any>(
`SELECT * FROM screen_layouts
WHERE screen_id = $1
ORDER BY display_order ASC NULLS LAST, layout_id ASC`,
[screenId]
);
console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`);
// 메타데이터와 컴포넌트 분리
const metadataLayout = layouts.find(
(layout) => layout.component_type === "_metadata"
);
const componentLayouts = layouts.filter(
(layout) => layout.component_type !== "_metadata"
);
// 기본 메타데이터 설정
let gridSettings = {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
};
let screenResolution = null;
// 저장된 메타데이터가 있으면 적용
if (metadataLayout && metadataLayout.properties) {
const metadata = metadataLayout.properties as any;
if (metadata.gridSettings) {
gridSettings = { ...gridSettings, ...metadata.gridSettings };
}
if (metadata.screenResolution) {
screenResolution = metadata.screenResolution;
}
console.log(`메타데이터 로드:`, { gridSettings, screenResolution });
}
if (componentLayouts.length === 0) {
return {
components: [],
gridSettings,
screenResolution,
};
}
// 🔥 최신 inputType 정보 조회 (table_type_columns에서)
const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode);
const components: ComponentData[] = componentLayouts.map((layout) => {
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 = {
id: layout.component_id,
// 🔥 최신 componentType이 있으면 type 덮어쓰기
type: latestTypeInfo?.componentType || layout.component_type as any,
position: {
x: layout.position_x,
y: layout.position_y,
z: properties?.position?.z || 1, // z 값 복원
},
size: { width: layout.width, height: layout.height },
parentId: layout.parent_id,
...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(`로드된 컴포넌트:`, {
id: component.id,
type: component.type,
position: component.position,
size: component.size,
parentId: component.parentId,
title: (component as any).title,
widgetType: (component as any).widgetType,
componentType: (component as any).componentType,
latestTypeInfo,
});
return component;
});
console.log(`=== 레이아웃 로드 완료 ===`);
console.log(`반환할 컴포넌트 수: ${components.length}`);
console.log(`최종 격자 설정:`, gridSettings);
console.log(`최종 해상도 설정:`, screenResolution);
console.log(`테이블명:`, existingScreen.table_name);
return {
components,
gridSettings,
screenResolution,
tableName: existingScreen.table_name, // 🆕 테이블명 추가
};
}
/**
* 입력 타입에 해당하는 컴포넌트 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;
}
// ========================================
// 템플릿 관리
// ========================================
/**
* 템플릿 목록 조회 (회사별) (✅ Raw Query 전환 완료)
*/
async getTemplatesByCompany(
companyCode: string,
type?: string,
isPublic?: boolean
): Promise<ScreenTemplate[]> {
const whereConditions: string[] = [];
const params: any[] = [];
if (companyCode !== "*") {
whereConditions.push(`company_code = $${params.length + 1}`);
params.push(companyCode);
}
if (type) {
whereConditions.push(`template_type = $${params.length + 1}`);
params.push(type);
}
if (isPublic !== undefined) {
whereConditions.push(`is_public = $${params.length + 1}`);
params.push(isPublic);
}
const whereSQL =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const templates = await query<any>(
`SELECT * FROM screen_templates
${whereSQL}
ORDER BY created_date DESC`,
params
);
return templates.map(this.mapToScreenTemplate);
}
/**
* 템플릿 생성 (✅ Raw Query 전환 완료)
*/
async createTemplate(
templateData: Partial<ScreenTemplate>
): Promise<ScreenTemplate> {
const [template] = await query<any>(
`INSERT INTO screen_templates (
template_name, template_type, company_code, description,
layout_data, is_public, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
templateData.templateName!,
templateData.templateType!,
templateData.companyCode!,
templateData.description || null,
templateData.layoutData
? JSON.stringify(JSON.parse(JSON.stringify(templateData.layoutData)))
: null,
templateData.isPublic || false,
templateData.createdBy || null,
]
);
return this.mapToScreenTemplate(template);
}
// ========================================
// 메뉴 할당 관리
// ========================================
/**
* 화면-메뉴 할당 (✅ Raw Query 전환 완료)
*/
async assignScreenToMenu(
screenId: number,
assignmentData: MenuAssignmentRequest
): Promise<void> {
// 중복 할당 방지
const existing = await query<{ assignment_id: number }>(
`SELECT assignment_id FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3
LIMIT 1`,
[screenId, assignmentData.menuObjid, assignmentData.companyCode]
);
if (existing.length > 0) {
throw new Error("이미 할당된 화면입니다.");
}
// screen_menu_assignments에 할당 추가
await query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, created_by
) VALUES ($1, $2, $3, $4, $5)`,
[
screenId,
assignmentData.menuObjid,
assignmentData.companyCode,
assignmentData.displayOrder || 0,
assignmentData.createdBy || null,
]
);
// 화면 정보 조회 (screen_code 가져오기)
const screen = await queryOne<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
if (screen) {
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정)
// 관리자 메뉴인지 확인
const menu = await queryOne<{ menu_type: string }>(
`SELECT menu_type FROM menu_info WHERE objid = $1`,
[assignmentData.menuObjid]
);
const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin");
const menuUrl = isAdminMenu
? `/screens/${screenId}?mode=admin`
: `/screens/${screenId}`;
await query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[menuUrl, screen.screen_code, assignmentData.menuObjid]
);
logger.info("화면 할당 완료 (menu_info 업데이트)", {
screenId,
menuObjid: assignmentData.menuObjid,
menuUrl,
screenCode: screen.screen_code,
});
}
}
/**
* 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료)
*/
async getScreensByMenu(
menuObjid: number,
companyCode: string
): Promise<ScreenDefinition[]> {
const screens = await query<any>(
`SELECT sd.* FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = $1
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY sma.display_order ASC`,
[menuObjid, companyCode]
);
return screens.map((screen) => this.mapToScreenDefinition(screen));
}
/**
* 화면에 할당된 메뉴 조회 (첫 번째 할당만 반환)
* 화면 편집기에서 menuObjid를 가져오기 위해 사용
*/
async getMenuByScreen(
screenId: number,
companyCode: string
): Promise<{ menuObjid: number; menuName?: string } | null> {
const result = await queryOne<{
menu_objid: string;
menu_name_kor?: string;
}>(
`SELECT sma.menu_objid, mi.menu_name_kor
FROM screen_menu_assignments sma
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
WHERE sma.screen_id = $1
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY sma.created_date ASC
LIMIT 1`,
[screenId, companyCode]
);
if (!result) {
return null;
}
return {
menuObjid: parseInt(result.menu_objid),
menuName: result.menu_name_kor,
};
}
/**
* 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료)
*/
async unassignScreenFromMenu(
screenId: number,
menuObjid: number,
companyCode: string
): Promise<void> {
// screen_menu_assignments에서 할당 삭제
await query(
`DELETE FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
[screenId, menuObjid, companyCode]
);
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거)
await query(
`UPDATE menu_info
SET menu_url = NULL, screen_code = NULL
WHERE objid = $1`,
[menuObjid]
);
logger.info("화면 할당 해제 완료 (menu_info 업데이트)", {
screenId,
menuObjid,
companyCode,
});
}
// ========================================
// 테이블 타입 연계
// ========================================
/**
* 컬럼 정보 조회 (웹 타입 포함) (✅ Raw Query 전환 완료)
*/
async getColumnInfo(tableName: string): Promise<ColumnInfo[]> {
const columns = await query<any>(
`SELECT
c.column_name,
COALESCE(cl.column_label, c.column_name) as column_label,
c.data_type,
COALESCE(cl.input_type, 'text') as web_type,
c.is_nullable,
c.column_default,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
cl.detail_settings,
cl.code_category,
cl.reference_table,
cl.reference_column,
cl.display_column,
cl.is_visible,
cl.display_order,
cl.description
FROM information_schema.columns c
LEFT JOIN column_labels cl ON c.table_name = cl.table_name
AND c.column_name = cl.column_name
WHERE c.table_name = $1
ORDER BY COALESCE(cl.display_order, c.ordinal_position)`,
[tableName]
);
return columns as ColumnInfo[];
}
/**
* 입력 타입 설정 (✅ Raw Query 전환 완료)
*/
async setColumnWebType(
tableName: string,
columnName: string,
webType: WebType,
additionalSettings?: Partial<ColumnWebTypeSetting>
): Promise<void> {
// UPSERT를 INSERT ... ON CONFLICT로 변환 (input_type 사용)
await query(
`INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
code_category, reference_table, reference_column, display_column,
is_visible, display_order, description, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = $4,
column_label = $3,
detail_settings = $5,
code_category = $6,
reference_table = $7,
reference_column = $8,
display_column = $9,
is_visible = $10,
display_order = $11,
description = $12,
updated_date = $14`,
[
tableName,
columnName,
additionalSettings?.columnLabel || null,
webType,
additionalSettings?.detailSettings
? JSON.stringify(additionalSettings.detailSettings)
: null,
additionalSettings?.codeCategory || null,
additionalSettings?.referenceTable || null,
additionalSettings?.referenceColumn || null,
(additionalSettings as any)?.displayColumn || null,
additionalSettings?.isVisible ?? true,
additionalSettings?.displayOrder ?? 0,
additionalSettings?.description || null,
new Date(),
new Date(),
]
);
}
/**
* 웹 타입별 위젯 생성
*/
generateWidgetFromColumn(column: ColumnInfo): WidgetData {
const baseWidget = {
id: generateId(),
tableName: column.tableName,
columnName: column.columnName,
type: column.webType || "text",
label: column.columnLabel || column.columnName,
required: column.isNullable === "N",
readonly: false,
};
// detail_settings JSON 파싱
const detailSettings = column.detailSettings
? JSON.parse(column.detailSettings)
: {};
switch (column.webType) {
case "text":
return {
...baseWidget,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
placeholder: `Enter ${column.columnLabel || column.columnName}`,
pattern: detailSettings.pattern,
};
case "number":
return {
...baseWidget,
min: detailSettings.min,
max:
detailSettings.max ||
(column.numericPrecision
? Math.pow(10, column.numericPrecision) - 1
: undefined),
step:
detailSettings.step ||
(column.numericScale && column.numericScale > 0
? Math.pow(10, -column.numericScale)
: 1),
};
case "date":
return {
...baseWidget,
format: detailSettings.format || "YYYY-MM-DD",
minDate: detailSettings.minDate,
maxDate: detailSettings.maxDate,
};
case "code":
return {
...baseWidget,
codeCategory: column.codeCategory,
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "entity":
return {
...baseWidget,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
searchable: detailSettings.searchable || true,
multiple: detailSettings.multiple || false,
};
case "textarea":
return {
...baseWidget,
rows: detailSettings.rows || 3,
maxLength: detailSettings.maxLength || column.characterMaximumLength,
};
case "select":
return {
...baseWidget,
options: detailSettings.options || [],
multiple: detailSettings.multiple || false,
searchable: detailSettings.searchable || false,
};
case "checkbox":
return {
...baseWidget,
defaultChecked: detailSettings.defaultChecked || false,
label: detailSettings.label || column.columnLabel,
};
case "radio":
return {
...baseWidget,
options: detailSettings.options || [],
inline: detailSettings.inline || false,
};
case "file":
return {
...baseWidget,
accept: detailSettings.accept || "*/*",
maxSize: detailSettings.maxSize || 10485760, // 10MB
multiple: detailSettings.multiple || false,
};
default:
return {
...baseWidget,
type: "text",
};
}
}
// ========================================
// 유틸리티 메서드
// ========================================
private mapToScreenDefinition(
data: any,
tableLabelMap?: Map<string, string>
): ScreenDefinition {
const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name;
return {
screenId: data.screen_id,
screenName: data.screen_name,
screenCode: data.screen_code,
tableName: data.table_name,
tableLabel: tableLabel, // 라벨이 있으면 라벨, 없으면 테이블명
companyCode: data.company_code,
description: data.description,
isActive: data.is_active,
createdDate: data.created_date,
createdBy: data.created_by,
updatedDate: data.updated_date,
updatedBy: data.updated_by,
dbSourceType: data.db_source_type || "internal",
dbConnectionId: data.db_connection_id || undefined,
// REST API 관련 필드
dataSourceType: data.data_source_type || "database",
restApiConnectionId: data.rest_api_connection_id || undefined,
restApiEndpoint: data.rest_api_endpoint || undefined,
restApiJsonPath: data.rest_api_json_path || "data",
};
}
private mapToScreenTemplate(data: any): ScreenTemplate {
return {
templateId: data.template_id,
templateName: data.template_name,
templateType: data.template_type,
companyCode: data.company_code,
description: data.description,
layoutData: data.layout_data,
isPublic: data.is_public,
createdBy: data.created_by,
createdDate: data.created_date,
};
}
/**
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
* 동시성 문제 방지: Advisory Lock 사용
*/
async generateScreenCode(companyCode: string): Promise<string> {
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]);
// 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지)
// LIMIT 제거하고 숫자 추출하여 최대값 찾기
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE screen_code LIKE $1
ORDER BY screen_code DESC`,
[`${companyCode}_%`]
);
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`
);
console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`);
console.log(`🔍 패턴: ${pattern}`);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`);
if (number > maxNumber) {
maxNumber = number;
}
}
}
// 다음 순번으로 화면 코드 생성
const nextNumber = maxNumber + 1;
// 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩
const newCode = `${companyCode}_${nextNumber}`;
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`);
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/modal/edit/openModalWithData 액션의 targetScreenId
* - 조건부 컨테이너: sections[].screenId (조건별 화면 할당)
* - 중첩된 화면들도 모두 감지 (재귀)
*/
async detectLinkedModalScreens(
screenId: number
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
const allLinkedScreenIds = new Set<number>();
const visited = new Set<number>(); // 무한 루프 방지
const queue: number[] = [screenId]; // BFS 큐
// BFS로 연결된 모든 화면 탐색
while (queue.length > 0) {
const currentScreenId = queue.shift()!;
// 이미 방문한 화면은 스킵 (순환 참조 방지)
if (visited.has(currentScreenId)) {
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
continue;
}
visited.add(currentScreenId);
console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.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`,
[currentScreenId]
);
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
// 각 레이아웃에서 연결된 화면 ID 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 1. 버튼 컴포넌트의 액션 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (modalActionTypes.includes(action?.type) && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (targetScreenId !== screenId) {
allLinkedScreenIds.add(targetScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(targetScreenId)) {
queue.push(targetScreenId);
console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`);
}
}
}
}
// 2. conditional-container 컴포넌트의 sections 확인
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const sectionScreenId = parseInt(section.screenId);
if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (sectionScreenId !== screenId) {
allLinkedScreenIds.add(sectionScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(sectionScreenId)) {
queue.push(sectionScreenId);
console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`);
}
}
}
}
}
} catch (error) {
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
}
}
console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}`);
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`);
// 감지된 화면 ID들의 정보 조회
if (allLinkedScreenIds.size === 0) {
console.log(` 연결된 화면이 없습니다.`);
return [];
}
const screenIds = Array.from(allLinkedScreenIds);
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
);
console.log(`\n📋 최종 감지된 화면 목록:`);
linkedScreens.forEach((s: any) => {
console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`);
});
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
screenCode: s.screen_code,
}));
}
/**
* 화면 복사 (화면 정보 + 레이아웃 모두 복사) (✅ Raw Query 전환 완료)
*/
async copyScreen(
sourceScreenId: number,
copyData: CopyScreenRequest
): Promise<ScreenDefinition> {
// 트랜잭션으로 처리
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>(
sourceScreenQuery,
sourceScreenParams
);
if (sourceScreens.rows.length === 0) {
throw new Error("복사할 화면을 찾을 수 없습니다.");
}
const sourceScreen = sourceScreens.rows[0];
// 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, targetCompanyCode]
);
if (existingScreens.rows.length > 0) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 4. 새 화면 생성 (대상 회사에 생성)
const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name,
is_active, created_by, created_date, updated_by, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
copyData.screenCode,
copyData.screenName,
copyData.description || sourceScreen.description,
targetCompanyCode, // 대상 회사 코드 사용
sourceScreen.table_name,
sourceScreen.is_active,
copyData.createdBy,
new Date(),
copyData.createdBy,
new Date(),
]
);
const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayoutsResult = await client.query<any>(
`SELECT * FROM screen_layouts
WHERE screen_id = $1
ORDER BY display_order ASC NULLS LAST`,
[sourceScreenId]
);
const sourceLayouts = sourceLayoutsResult.rows;
// 5. 레이아웃이 있다면 복사
if (sourceLayouts.length > 0) {
try {
// ID 매핑 맵 생성
const idMapping: { [oldId: string]: string } = {};
// 새로운 컴포넌트 ID 미리 생성
sourceLayouts.forEach((layout: any) => {
idMapping[layout.component_id] = generateId();
});
// 각 레이아웃 컴포넌트 복사
for (const sourceLayout of sourceLayouts) {
const newComponentId = idMapping[sourceLayout.component_id];
const newParentId = sourceLayout.parent_id
? idMapping[sourceLayout.parent_id]
: null;
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, created_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
newScreen.screen_id,
sourceLayout.component_type,
newComponentId,
newParentId,
Math.round(sourceLayout.position_x), // 정수로 반올림
Math.round(sourceLayout.position_y), // 정수로 반올림
Math.round(sourceLayout.width), // 정수로 반올림
Math.round(sourceLayout.height), // 정수로 반올림
typeof sourceLayout.properties === "string"
? sourceLayout.properties
: JSON.stringify(sourceLayout.properties),
sourceLayout.display_order,
new Date(),
]
);
}
} catch (error) {
console.error("레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
// 6. 생성된 화면 정보 반환
return {
screenId: newScreen.screen_id,
screenCode: newScreen.screen_code,
screenName: newScreen.screen_name,
description: newScreen.description || "",
companyCode: newScreen.company_code,
tableName: newScreen.table_name,
isActive: newScreen.is_active,
createdBy: newScreen.created_by || undefined,
createdDate: newScreen.created_date,
updatedBy: newScreen.updated_by || undefined,
updatedDate: newScreen.updated_date,
};
});
}
/**
* 메인 화면 + 연결된 모달 화면들 일괄 복사
*/
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,
createdBy: 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,
createdBy: 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;
let needsUpdate = false;
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (
modalActionTypes.includes(action?.type) &&
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();
needsUpdate = true;
console.log(
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const oldScreenId = parseInt(section.screenId);
console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// section.screenId 업데이트
section.screenId = newScreenId;
needsUpdate = true;
console.log(
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
}
// 3. 업데이트가 필요한 경우 DB 저장
if (needsUpdate) {
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`);
return updateCount;
}
}
// 서비스 인스턴스 export
export const screenManagementService = new ScreenManagementService();