feat: 멀티테넌시 지원을 위한 레이어 관리 기능 추가

- 레이어 목록 조회, 특정 레이어 레이아웃 조회, 레이어 삭제 및 조건 설정 업데이트 기능을 추가했습니다.
- 엔티티 참조 데이터 조회 및 공통 코드 데이터 조회에 멀티테넌시 필터를 적용하여 인증된 사용자의 회사 코드에 따라 데이터 접근을 제한했습니다.
- 레이어 관리 패널에서 기본 레이어와 조건부 레이어의 컴포넌트를 통합하여 조건부 영역의 표시를 개선했습니다.
- 레이아웃 저장 시 레이어 ID를 포함하여 레이어별로 저장할 수 있도록 변경했습니다.
This commit is contained in:
kjs
2026-02-09 13:21:56 +09:00
parent 84eb035069
commit 1c71b3aa83
14 changed files with 1571 additions and 598 deletions

View File

@@ -30,10 +30,13 @@ export class EntityReferenceController {
try {
const { tableName, columnName } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
limit,
search,
companyCode,
});
// 컬럼 정보 조회 (table_type_columns에서)
@@ -89,16 +92,34 @@ export class EntityReferenceController {
});
}
// 동적 쿼리로 참조 데이터 조회
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
// 참조 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCode = await queryOne<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
[referenceTable]
);
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
const whereConditions: string[] = [];
const queryParams: any[] = [];
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
if (hasCompanyCode && companyCode && companyCode !== "*") {
queryParams.push(companyCode);
whereConditions.push(`company_code = $${queryParams.length}`);
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
}
// 검색 조건 추가
if (search) {
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
queryParams.push(`%${search}%`);
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
}
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
if (whereConditions.length > 0) {
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
}
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
queryParams.push(Number(limit));
@@ -107,6 +128,7 @@ export class EntityReferenceController {
referenceTable,
referenceColumn,
displayColumn,
companyCode,
});
const referenceData = await query<any>(sqlQuery, queryParams);
@@ -119,7 +141,7 @@ export class EntityReferenceController {
})
);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,
@@ -149,13 +171,16 @@ export class EntityReferenceController {
try {
const { codeCategory } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
limit,
search,
companyCode,
});
// code_info 테이블에서 코드 데이터 조회
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
const queryParams: any[] = [codeCategory, 'Y'];
let sqlQuery = `
SELECT code_value, code_name
@@ -163,9 +188,16 @@ export class EntityReferenceController {
WHERE code_category = $1 AND is_active = $2
`;
// 멀티테넌시: company_code 필터링
if (companyCode && companyCode !== "*") {
queryParams.push(companyCode);
sqlQuery += ` AND company_code = $${queryParams.length}`;
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
}
if (search) {
sqlQuery += ` AND code_name ILIKE $3`;
queryParams.push(`%${search}%`);
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
}
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
@@ -174,12 +206,12 @@ export class EntityReferenceController {
const codeData = await query<any>(sqlQuery, queryParams);
// 옵션 형태로 변환
const options: EntityReferenceOption[] = codeData.map((code) => ({
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
value: code.code_value,
label: code.code_name,
}));
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,

View File

@@ -732,6 +732,61 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
}
};
// 🆕 레이어 목록 조회
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
res.json({ success: true, data: layers });
} catch (error) {
console.error("레이어 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
}
};
// 🆕 특정 레이어 레이아웃 조회
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, data: layout });
} catch (error) {
console.error("레이어 레이아웃 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
}
};
// 🆕 레이어 삭제
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, message: "레이어가 삭제되었습니다." });
} catch (error: any) {
console.error("레이어 삭제 실패:", error);
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
}
};
// 🆕 레이어 조건 설정 업데이트
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const { conditionConfig, layerName } = req.body;
await screenManagementService.updateLayerCondition(
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
);
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
} catch (error) {
console.error("레이어 조건 업데이트 실패:", error);
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
}
};
// 화면 코드 자동 생성
export const generateScreenCode = async (
req: AuthenticatedRequest,

View File

@@ -38,6 +38,10 @@ import {
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
getScreenLayers,
getLayerLayout,
deleteLayer,
updateLayerCondition,
} from "../controllers/screenManagementController";
const router = express.Router();
@@ -84,6 +88,12 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 🆕 레이어 관리
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 메뉴-화면 할당 관리
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
router.get("/menus/:menuObjid/screens", getScreensByMenu);

View File

@@ -4245,11 +4245,11 @@ export class ScreenManagementService {
},
);
// V2 레이아웃 저장 (UPSERT)
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
@@ -5073,38 +5073,63 @@ export class ScreenManagementService {
let layout: { layout_data: any } | null = null;
// 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드로 레이아웃 조회
// 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, existingScreen.company_code],
);
}
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY updated_at DESC
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
// 일반 사용자: 회사별 우선 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode],
);
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, companyCode],
);
}
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
@@ -5122,17 +5147,21 @@ export class ScreenManagementService {
}
/**
* V2 레이아웃 저장 (1 레코드 방식)
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
* - layout_data JSON에 모든 컴포넌트 포함
* V2 레이아웃 저장 (레이어별 저장)
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
*/
async saveLayoutV2(
screenId: number,
layoutData: any,
companyCode: string,
): Promise<void> {
const layerId = layoutData.layerId || 1;
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
const conditionConfig = layoutData.conditionConfig || null;
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
// 권한 확인
@@ -5151,22 +5180,173 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
...layoutData
...pureLayoutData,
};
// UPSERT (있으면 업데이트, 없으면 삽입)
// UPSERT (레이어별 저장)
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(dataToSave)],
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
console.log(`V2 레이아웃 저장 완료`);
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
}
/**
* 화면의 모든 레이어 목록 조회
* 레이어가 없으면 기본 레이어를 자동 생성
*/
async getScreenLayers(
screenId: number,
companyCode: string,
): Promise<any[]> {
let layers;
if (companyCode === "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY layer_id`,
[screenId],
);
} else {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
// 회사별 레이어가 없으면 공통(*) 레이어 조회
if (layers.length === 0 && companyCode !== "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
[screenId],
);
}
}
// 레이어가 없으면 기본 레이어 자동 생성
if (layers.length === 0) {
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
[screenId, companyCode, defaultLayout],
);
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
// 다시 조회
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
}
return layers;
}
/**
* 특정 레이어의 레이아웃 조회
*/
async getLayerLayout(
screenId: number,
layerId: number,
companyCode: string,
): Promise<any> {
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
// 회사별 레이어가 없으면 공통(*) 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
[screenId, layerId],
);
}
if (!layout) return null;
return {
...layout.layout_data,
layerId,
layerName: layout.layer_name,
conditionConfig: layout.condition_config,
};
}
/**
* 레이어 삭제
*/
async deleteLayer(
screenId: number,
layerId: number,
companyCode: string,
): Promise<void> {
if (layerId === 1) {
throw new Error("기본 레이어는 삭제할 수 없습니다.");
}
await query(
`DELETE FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
}
/**
* 레이어 조건 설정 업데이트
*/
async updateLayerCondition(
screenId: number,
layerId: number,
companyCode: string,
conditionConfig: any,
layerName?: string,
): Promise<void> {
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
if (layerName) {
setClauses.push(`layer_name = $${params.length + 1}`);
params.push(layerName);
}
await query(
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
params,
);
}
}