2900 lines
91 KiB
TypeScript
2900 lines
91 KiB
TypeScript
// ✅ 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();
|