Compare commits

...

3 Commits

Author SHA1 Message Date
kjs 589f5b9222 feat: 화면 기본 테이블 업데이트 기능 추가
- 화면의 기본 테이블을 업데이트하는 기능을 추가하였습니다. 사용자가 선택한 테이블 이름을 화면 레이아웃에 저장하고, 해당 정보를 기반으로 데이터베이스의 화면 정의를 업데이트합니다.
- 관련된 로그 메시지를 추가하여 업데이트 성공 여부를 콘솔에 기록하도록 하였습니다.
- 화면 디자인에서 현재 선택된 테이블을 기본 테이블로 설정하는 로직을 포함하였습니다.
2026-01-27 09:44:26 +09:00
kjs 1753822211 feat: 테스트 테이블에서 채번 규칙 목록 조회 API 추가 및 회사별 카테고리 컬럼 조회 기능 구현
- 테스트 테이블에서 채번 규칙 목록을 조회하는 API를 추가하였습니다. 이 API는 회사 코드와 선택적 메뉴 OBJID를 기반으로 규칙을 반환합니다.
- 회사별 카테고리 컬럼을 조회하는 API를 추가하여, 회사 코드에 따라 카테고리 컬럼을 필터링하여 반환하도록 개선하였습니다.
- 관련된 서비스 및 라우터를 업데이트하여 새로운 기능을 통합하였습니다.
2026-01-26 16:32:20 +09:00
kjs 9b8546ebef fix: 화면 생성 실패 시 에러 메시지 출력 추가
- 화면 생성 중 오류 발생 시, 콘솔에 에러를 기록하고 사용자에게 알림을 통해 에러 메시지를 표시하도록 수정하였습니다.
- 에러 메시지는 서버 응답에서 가져오거나 기본 메시지를 사용합니다.
2026-01-26 15:32:41 +09:00
13 changed files with 964 additions and 276 deletions

View File

@ -269,6 +269,23 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
// ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회
router.get("/test/list/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
logger.info("[테스트] 채번 규칙 목록 조회 요청", { companyCode, menuObjid });
try {
const rules = await numberingRuleService.getRulesFromTest(companyCode, menuObjid);
logger.info("[테스트] 채번 규칙 목록 조회 성공", { companyCode, menuObjid, count: rules.length });
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;

View File

@ -1642,6 +1642,113 @@ export async function toggleLogTable(
}
}
/**
* ( )
*
* @route GET /api/table-management/category-columns
* @description table_type_columns에서 input_type = 'category'
*/
export async function getCategoryColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("📥 회사별 카테고리 컬럼 조회 요청", { companyCode });
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length
});
}
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 회사별 카테고리 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* ( )
*
@ -1670,125 +1777,28 @@ export async function getCategoryColumnsByMenu(
return;
}
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { menuObjid, user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
// 1. category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists
`);
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
// category_column_mapping 대신 table_type_columns 기준으로 조회
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
let columnsResult;
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
--
SELECT objid, parent_obj_id, menu_type, menu_name_kor
FROM menu_info
WHERE objid = $1
UNION ALL
--
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
FROM menu_info m
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
WHERE m.parent_obj_id != 0 -- (parent_obj_id=0)
)
SELECT
ARRAY_AGG(objid) as menu_objids,
ARRAY_AGG(menu_name_kor) as menu_names
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
logger.info("✅ 상위 메뉴 계층 조회 완료", {
ancestorMenuObjids,
ancestorMenuNames,
hierarchyDepth: ancestorMenuObjids.length
});
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ccm.logical_column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType",
ccm.menu_objid AS "definedAtMenuObjid"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
AND ccm.physical_column_name = ttc.column_name
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ccm.company_code = $1
AND ccm.menu_objid = ANY($2)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
rowCount: columnsResult.rows.length,
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
});
} else {
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
// 형제 메뉴들이 사용하는 테이블 조회
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
@ -1806,14 +1816,46 @@ export async function getCategoryColumnsByMenu(
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length
});
}
logger.info("✅ 카테고리 컬럼 조회 완료", {

View File

@ -24,6 +24,7 @@ import {
getLogData,
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
@ -212,6 +213,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
// 메뉴 기반 카테고리 관리 API
// ========================================
/**
* ( )
* GET /api/table-management/category-columns
*/
router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -1196,6 +1196,109 @@ class NumberingRuleService {
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
}
/**
* []
* numbering_rules_test
*/
async getRulesFromTest(
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
try {
logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid });
const pool = getPool();
// 멀티테넌시: 최고 관리자 vs 일반 회사
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회
query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
ORDER BY created_at DESC
`;
params = [];
} else {
// 일반 회사: 자신의 규칙만 조회
query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
category_column AS "categoryColumn",
category_value_id AS "categoryValueId",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
WHERE company_code = $1
ORDER BY created_at DESC
`;
params = [companyCode];
}
const result = await pool.query(query, params);
// 각 규칙의 파트 정보 조회
for (const rule of result.rows) {
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
}
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
companyCode,
menuObjid,
count: result.rows.length,
});
return result.rows;
} catch (error: any) {
logger.error("[테스트] 채번 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* [] + (menu_objid )
* numbering_rules_test

View File

@ -1469,6 +1469,7 @@ export class ScreenManagementService {
console.log(`컴포넌트 수: ${layoutData.components.length}`);
console.log(`격자 설정:`, layoutData.gridSettings);
console.log(`해상도 설정:`, layoutData.screenResolution);
console.log(`기본 테이블:`, (layoutData as any).mainTableName);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
@ -1486,6 +1487,16 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 🆕 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = (layoutData as any).mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId]
);
console.log(`✅ 화면 기본 테이블 업데이트: ${mainTableName}`);
}
// 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두)
await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);

View File

@ -0,0 +1,392 @@
# column_labels 테이블 제거 영향 분석
## 개요
현재 시스템은 컬럼 메타데이터를 **두 개의 테이블**에서 관리하고 있습니다:
- `column_labels`: 레거시 테이블 (회사코드 없음, 공통 데이터)
- `table_type_columns`: 새 테이블 (회사코드 있음, 멀티테넌시 지원)
이 문서는 `column_labels` 테이블을 제거하고 `table_type_columns`로 통합할 때의 영향을 분석합니다.
---
## 1. 두 테이블 스키마 비교
### column_labels (레거시)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER | PK |
| table_name | VARCHAR | 테이블명 |
| column_name | VARCHAR | 컬럼명 |
| column_label | VARCHAR | 한글 라벨 |
| web_type | VARCHAR | 레거시 (사용 안 함) |
| input_type | VARCHAR | 입력 타입 |
| detail_settings | TEXT | 상세 설정 (JSON) |
| description | TEXT | 설명 |
| display_order | INTEGER | 표시 순서 |
| is_visible | BOOLEAN | 표시 여부 |
| code_category | VARCHAR | 코드 카테고리 |
| code_value | VARCHAR | 코드 값 |
| reference_table | VARCHAR | 참조 테이블 |
| reference_column | VARCHAR | 참조 컬럼 |
| display_column | VARCHAR | 표시 컬럼 |
| created_date | TIMESTAMP | 생성일 |
| updated_date | TIMESTAMP | 수정일 |
**특징**: `company_code` 없음 → 멀티테넌시 불가
### table_type_columns (신규)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | INTEGER | PK |
| table_name | VARCHAR | 테이블명 |
| column_name | VARCHAR | 컬럼명 |
| input_type | VARCHAR | 입력 타입 |
| detail_settings | TEXT | 상세 설정 (JSON) |
| is_nullable | VARCHAR | NULL 허용 |
| display_order | INTEGER | 표시 순서 |
| company_code | VARCHAR | **회사 코드** |
| created_date | TIMESTAMP | 생성일 |
| updated_date | TIMESTAMP | 수정일 |
**특징**: `company_code` 있음 → 멀티테넌시 지원
### 누락된 컬럼 (table_type_columns에 추가 필요)
| 컬럼 | 용도 |
|------|------|
| column_label | 한글 라벨 |
| description | 설명 |
| is_visible | 표시 여부 |
| code_category | 코드 카테고리 |
| code_value | 코드 값 |
| reference_table | 참조 테이블 (엔티티) |
| reference_column | 참조 컬럼 |
| display_column | 표시 컬럼 |
---
## 2. 영향 받는 파일 목록
### 백엔드 파일 (16개, 87회 참조)
| 파일 | 참조 횟수 | 영향도 | 용도 |
|------|----------|--------|------|
| `tableManagementService.ts` | 21회 | 🔴 매우 높음 | 컬럼 조회/저장/업데이트 핵심 로직 |
| `screenGroupController.ts` | 13회 | 🟡 중간 | 화면 그룹/메뉴 동기화 시 라벨 조회 |
| `masterDetailExcelService.ts` | 7회 | 🟡 중간 | 엑셀 다운로드 시 엔티티 관계 조회 |
| `ddlExecutionService.ts` | 7회 | 🟡 중간 | 테이블 생성/삭제 시 메타데이터 등록 |
| `tableManagementController.ts` | 7회 | 🟡 중간 | API 엔드포인트 |
| `screenManagementService.ts` | 5회 | 🔴 높음 | 화면에서 컬럼 라벨 조회 |
| `entityJoinService.ts` | 4회 | 🔴 높음 | 엔티티 조인 관계 감지 |
| `entityReferenceController.ts` | 4회 | 🟡 중간 | 엔티티 참조 데이터 조회 |
| `adminController.ts` | 3회 | 🟢 낮음 | 엑셀 업로드 컬럼 매핑 |
| `dataService.ts` | 3회 | 🟢 낮음 | 컬럼 라벨 조회 |
| `flowController.ts` | 3회 | 🟢 낮음 | 플로우 컬럼 라벨 조회 |
| `categoryTreeService.ts` | 1회 | 🟢 낮음 | 카테고리 라벨 조회 |
| `tableManagementRoutes.ts` | 1회 | 🟢 낮음 | 라우트 주석 |
| `multiConnectionQueryService.ts` | 1회 | 🟢 낮음 | 멀티 연결 쿼리 |
| `migrate-input-type-to-web-type.ts` | 6회 | 🟢 낮음 | 마이그레이션 스크립트 |
| `types/ddl.ts` | 1회 | 🟢 낮음 | 타입 정의 |
### 프론트엔드 파일 (15개, 20회 참조)
| 파일 | 참조 횟수 | 영향도 | 용도 |
|------|----------|--------|------|
| `UnifiedRepeater.tsx` | 3회 | 🟢 낮음 | 타입 주석 |
| `ScreenDesigner.tsx` | 2회 | 🟢 낮음 | 타입 주석 |
| `ButtonConfigPanel.tsx` | 2회 | 🟢 낮음 | 타입 주석 |
| `ScreenRelationFlow.tsx` | 2회 | 🟢 낮음 | 타입 주석 |
| `buttonActions.ts` | 1회 | 🟢 낮음 | 주석 |
| `webTypeMapping.ts` | 1회 | 🟢 낮음 | 주석 |
| `screenGroup.ts` | 1회 | 🟢 낮음 | API 타입 |
| `tableManagement.ts` | 1회 | 🟢 낮음 | API 타입 |
| `TableSettingModal.tsx` | 1회 | 🟢 낮음 | 주석 |
| `tableSchema.ts` | 1회 | 🟢 낮음 | 타입 |
| `types/ddl.ts` | 1회 | 🟢 낮음 | 타입 정의 |
| `ControlConditionStep.tsx` | 1회 | 🟢 낮음 | 주석 |
| `ActionConditionBuilder.tsx` | 1회 | 🟢 낮음 | 주석 |
| `WebTypeInput.tsx` | 1회 | 🟢 낮음 | 주석 |
| `types/multiConnection.ts` | 1회 | 🟢 낮음 | 타입 |
---
## 3. 주요 사용 패턴 분석
### 패턴 1: 컬럼 조회 (가장 많음)
```sql
-- 현재 방식: column_labels + table_type_columns 조인
SELECT
c.column_name,
COALESCE(cl.column_label, c.column_name) as "displayName",
COALESCE(ttc.input_type, cl.input_type, 'text') as "inputType",
COALESCE(ttc.detail_settings, cl.detail_settings) as "detailSettings",
cl.reference_table as "referenceTable" -- ❌ 문제: ttc의 detailSettings 무시
FROM information_schema.columns c
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name
AND c.column_name = ttc.column_name
AND ttc.company_code = $company_code
```
**문제점**: `referenceTable``column_labels`에서만 조회됨 → 회사별 엔티티 설정 무시
### 패턴 2: 컬럼 저장 (이중 저장)
```typescript
// 현재: 두 테이블에 모두 저장
await query(`INSERT INTO column_labels (...) VALUES (...) ON CONFLICT ... DO UPDATE ...`);
await this.updateColumnInputType(...); // table_type_columns에도 저장
```
**문제점**: 데이터 불일치 가능성, 유지보수 어려움
### 패턴 3: 엔티티 관계 조회
```sql
SELECT column_name, reference_table, reference_column
FROM column_labels
WHERE table_name = $1 AND input_type = 'entity'
```
**문제점**: 회사별 엔티티 설정 무시 (column_labels에 company_code 없음)
---
## 4. 마이그레이션 계획
### Phase 1: 스키마 확장 (table_type_columns)
```sql
-- 마이그레이션 파일: 044_extend_table_type_columns.sql
-- 1. 누락된 컬럼 추가
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS column_label VARCHAR(200);
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS description TEXT;
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS is_visible BOOLEAN DEFAULT true;
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS code_category VARCHAR(100);
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS code_value VARCHAR(100);
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS reference_table VARCHAR(100);
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS reference_column VARCHAR(100);
ALTER TABLE table_type_columns ADD COLUMN IF NOT EXISTS display_column VARCHAR(100);
-- 2. 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_ttc_reference_table ON table_type_columns(reference_table);
CREATE INDEX IF NOT EXISTS idx_ttc_input_type ON table_type_columns(input_type);
```
### Phase 2: 데이터 마이그레이션
```sql
-- column_labels 데이터를 table_type_columns로 이관 (company_code = '*' 로 공통 데이터)
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
column_label, description, is_visible, code_category, code_value,
reference_table, reference_column, display_column, display_order,
company_code, created_date, updated_date
)
SELECT
table_name, column_name,
COALESCE(input_type, 'text'),
detail_settings,
column_label,
description,
COALESCE(is_visible, true),
code_category,
code_value,
reference_table,
reference_column,
display_column,
display_order,
'*', -- 공통 데이터 (회사별 설정 없으면 폴백)
COALESCE(created_date, NOW()),
COALESCE(updated_date, NOW())
FROM column_labels
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
description = COALESCE(EXCLUDED.description, table_type_columns.description),
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
updated_date = NOW();
```
### Phase 3: 코드 수정
#### 3.1 조회 쿼리 변경 패턴
```sql
-- 변경 전
SELECT ... FROM column_labels WHERE table_name = $1
-- 변경 후 (회사코드 폴백 포함)
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY table_name, column_name
ORDER BY CASE WHEN company_code = $company_code THEN 0 ELSE 1 END
) as rn
FROM table_type_columns
WHERE table_name = $1
AND company_code IN ($company_code, '*')
) ranked
WHERE rn = 1
```
#### 3.2 저장 쿼리 변경 패턴
```sql
-- 변경 전: 두 테이블에 저장
INSERT INTO column_labels (...) ...;
INSERT INTO table_type_columns (...) ...;
-- 변경 후: 하나의 테이블만
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
column_label, description, is_visible, code_category, code_value,
reference_table, reference_column, display_column, display_order,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET ...;
```
### Phase 4: 레거시 코드 정리
1. `column_labels` 관련 INSERT/UPDATE 제거
2. `column_labels` LEFT JOIN을 `table_type_columns`로 변경
3. 프론트엔드 주석/타입 업데이트
### Phase 5: 테이블 삭제 (최종)
```sql
-- 모든 코드 마이그레이션 완료 후
DROP TABLE IF EXISTS column_labels;
```
---
## 5. 수정해야 할 파일 상세
### 🔴 우선순위 1: 핵심 서비스 (3개)
#### tableManagementService.ts (21개 쿼리)
| 함수 | 라인 | 수정 내용 |
|------|------|----------|
| `checkCodeTypeColumn` | 30-36 | column_labels → table_type_columns |
| `getColumnList` | 215-218, 257-258 | JOIN 변경 + referenceTable 추출 |
| `updateColumnSettings` | 460-494 | column_labels INSERT 제거 |
| `getColumnLabels` | 670-673 | table_type_columns로 변경 |
| `setColumnInputType` | 735-740 | column_labels INSERT 제거 |
| `getFileColumns` | 1288 | table_type_columns로 변경 |
| `getColumnMetaInfo` | 1956 | table_type_columns로 변경 |
| `findEntityRelation` | 3579-3590 | table_type_columns로 변경 |
| `upsertColumnLabel` | 3723 | table_type_columns로 변경 |
| `getColumnInputTypes` | 4129 | 이미 table_type_columns 사용 중 ✅ |
| `detectEntityRelation` | 4810, 4838 | table_type_columns로 변경 |
#### screenManagementService.ts (5개 쿼리)
| 함수 | 라인 | 수정 내용 |
|------|------|----------|
| `getTableColumns` | 1279 | table_type_columns로 변경 |
| `getTableColumns` | 1334 | 라벨 추가 로직 수정 |
| `getColumnInfo` | 2083 | table_type_columns로 변경 |
| `saveColumnSettings` | 2104 | table_type_columns로 변경 |
#### entityJoinService.ts (4개 쿼리)
| 함수 | 라인 | 수정 내용 |
|------|------|----------|
| `detectEntityColumns` | 36 | table_type_columns로 변경 |
| `getTableColumns` | 755 | table_type_columns로 변경 |
### 🟡 우선순위 2: 보조 서비스 (5개)
| 파일 | 수정 쿼리 수 | 수정 내용 |
|------|-------------|----------|
| `screenGroupController.ts` | 13개 | 라벨 조회, FK 조회 쿼리 변경 |
| `masterDetailExcelService.ts` | 7개 | 엔티티 관계 조회 변경 |
| `ddlExecutionService.ts` | 7개 | 테이블 생성 시 메타데이터 등록 변경 |
| `entityReferenceController.ts` | 4개 | 참조 데이터 조회 변경 |
| `adminController.ts` | 3개 | 스키마 조회 변경 |
### 🟢 우선순위 3: 기타 (8개)
| 파일 | 수정 내용 |
|------|----------|
| `dataService.ts` | 라벨 조회 변경 |
| `flowController.ts` | 라벨 조회 변경 |
| `categoryTreeService.ts` | JOIN 변경 |
| `tableManagementRoutes.ts` | 주석 수정 |
| `multiConnectionQueryService.ts` | 주석/타입 수정 |
| `migrate-input-type-to-web-type.ts` | 마이그레이션 스크립트 (이미 실행됨, 삭제 가능) |
| `types/ddl.ts` | 타입 정의 수정 |
| 프론트엔드 15개 파일 | 주석/타입 수정 |
---
## 6. 예상 작업 시간
| 단계 | 작업 | 예상 시간 |
|------|------|----------|
| Phase 1 | 스키마 확장 (마이그레이션 SQL) | 30분 |
| Phase 2 | 데이터 마이그레이션 (SQL) | 30분 |
| Phase 3.1 | tableManagementService.ts 수정 | 2시간 |
| Phase 3.2 | screenManagementService.ts 수정 | 1시간 |
| Phase 3.3 | entityJoinService.ts 수정 | 30분 |
| Phase 3.4 | 보조 서비스 5개 수정 | 2시간 |
| Phase 3.5 | 기타 파일 8개 수정 | 1시간 |
| Phase 4 | 테스트 및 검증 | 2시간 |
| Phase 5 | column_labels 삭제 | 10분 |
| **합계** | | **약 10시간** |
---
## 7. 리스크 및 주의사항
### 높은 리스크
1. **데이터 불일치**: 마이그레이션 중 column_labels와 table_type_columns 데이터 충돌 가능
2. **회사코드 폴백 로직 복잡성**: 모든 조회에 `company_code IN ($code, '*')` + 우선순위 필요
3. **기존 운영 데이터 손실**: 마이그레이션 실수 시 column_labels 데이터 유실 가능
### 완화 방안
1. **단계적 마이그레이션**: column_labels는 당분간 유지, 조회만 table_type_columns 우선으로 변경
2. **폴백 헬퍼 함수**: 회사코드 폴백 로직을 공통 함수로 추출
3. **백업 필수**: 마이그레이션 전 column_labels 전체 백업
---
## 8. 결론
### 현재 문제점
1. **이중 관리**: 같은 데이터가 두 테이블에 저장됨
2. **멀티테넌시 불완전**: `referenceTable` 등이 `column_labels`에서만 조회되어 회사별 설정 무시
3. **유지보수 어려움**: 변경 시 두 곳 모두 수정 필요
### 권장 방향
**장기적으로 `table_type_columns`로 통합 권장**
하지만 작업량이 상당하므로:
1. **단기 (즉시)**: 조회 시 `detailSettings`에서 `referenceTable` 우선 추출하도록 수정
2. **중기 (1-2주)**: `table_type_columns` 스키마 확장 + 데이터 마이그레이션
3. **장기 (한 달)**: 모든 코드 수정 후 `column_labels` 제거
---
## 참고 자료
- `table_type_columns` 관련 마이그레이션: `db/migrations/030_create_table_type_columns.sql`
- 테이블 타입 관리 UI: `frontend/app/(main)/admin/systemMng/tableMngList/page.tsx`
- 컬럼 조회 핵심 로직: `backend-node/src/services/tableManagementService.ts:getColumnList()`

View File

@ -12,9 +12,9 @@ import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorTyp
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
getAvailableNumberingRules,
saveNumberingRuleToTest,
deleteNumberingRuleFromTest,
getNumberingRulesFromTest,
} from "@/lib/api/numberingRule";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -159,14 +159,15 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const loadRules = useCallback(async () => {
setLoading(true);
try {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작:", {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
const response = await getAvailableNumberingRules(menuObjid);
// test 테이블에서 조회
const response = await getNumberingRulesFromTest(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답:", {
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,

View File

@ -265,8 +265,10 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
setSelectedRestApiId(null);
setRestApiEndpoint("");
setRestApiJsonPath("data");
} catch (e) {
// 필요 시 토스트 추가 가능
} catch (e: any) {
console.error("화면 생성 실패:", e);
const errorMessage = e?.response?.data?.message || e?.message || "화면 생성에 실패했습니다.";
alert(errorMessage);
} finally {
setSubmitting(false);
}

View File

@ -1650,10 +1650,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
// 해상도 정보를 포함한 레이아웃 데이터 생성
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
const layoutWithResolution = {
...layout,
components: updatedComponents,
screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
// 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter(
@ -1687,7 +1691,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} finally {
setIsSaving(false);
}
}, [selectedScreen, layout, screenResolution]);
}, [selectedScreen, layout, screenResolution, tables]);
// 다국어 자동 생성 핸들러
const handleGenerateMultilang = useCallback(async () => {

View File

@ -33,10 +33,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface GridSettings {
columns: number;
@ -173,112 +175,118 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
{screenResolution && (
<>
<div className="h-6 w-px bg-gray-300" />
<Popover open={showCustomInput} onOpenChange={setShowCustomInput}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5 transition-colors hover:bg-blue-100">
{getCategoryIcon(screenResolution.category || "desktop")}
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
<span className="text-xs text-blue-600">
({screenResolution.width} × {screenResolution.height})
</span>
{onResolutionChange && <ChevronDown className="h-3 w-3 text-blue-600" />}
</button>
</DropdownMenuTrigger>
{onResolutionChange && (
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Monitor className="h-4 w-4 text-blue-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500">릿</DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Tablet className="h-4 w-4 text-green-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Smartphone className="h-4 w-4 text-purple-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"> </DropdownMenuLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5 transition-colors hover:bg-blue-100">
{getCategoryIcon(screenResolution.category || "desktop")}
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
<span className="text-xs text-blue-600">
({screenResolution.width} × {screenResolution.height})
</span>
{onResolutionChange && <ChevronDown className="h-3 w-3 text-blue-600" />}
</button>
</DropdownMenuTrigger>
{onResolutionChange && (
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setCustomWidth(screenResolution.width.toString());
setCustomHeight(screenResolution.height.toString());
setShowCustomInput(true);
}}
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Settings className="h-4 w-4 text-gray-600" />
<span className="flex-1"> ...</span>
<Monitor className="h-4 w-4 text-blue-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<div className="text-sm font-medium"> </div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
placeholder="1920"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"> (px)</Label>
<Input
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="1080"
className="h-8 text-xs"
/>
</div>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500">릿</DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Tablet className="h-4 w-4 text-green-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"></DropdownMenuLabel>
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
<DropdownMenuItem
key={resolution.name}
onClick={() => onResolutionChange(resolution)}
className="flex items-center space-x-2"
>
<Smartphone className="h-4 w-4 text-purple-600" />
<span className="flex-1">{resolution.name}</span>
<span className="text-xs text-gray-400">
{resolution.width}×{resolution.height}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-gray-500"> </DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
setCustomWidth(screenResolution.width.toString());
setCustomHeight(screenResolution.height.toString());
setShowCustomInput(true);
}}
className="flex items-center space-x-2"
>
<Settings className="h-4 w-4 text-gray-600" />
<span className="flex-1"> ...</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
{/* 사용자 정의 해상도 다이얼로그 */}
<Dialog open={showCustomInput} onOpenChange={setShowCustomInput}>
<DialogContent className="sm:max-w-[320px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="customWidth" className="text-sm"> (px)</Label>
<Input
id="customWidth"
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(e.target.value)}
placeholder="1920"
/>
</div>
<Button onClick={handleCustomResolution} size="sm" className="w-full">
<div className="space-y-2">
<Label htmlFor="customHeight" className="text-sm"> (px)</Label>
<Input
id="customHeight"
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="1080"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCustomInput(false)}>
</Button>
<Button onClick={handleCustomResolution}>
</Button>
</div>
</PopoverContent>
</Popover>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}

View File

@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { FolderTree, Loader2 } from "lucide-react";
import { FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
interface CategoryColumn {
tableName: string;
@ -28,17 +29,29 @@ interface CategoryColumnListProps {
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
// 검색어로 필터링된 컬럼 목록
const filteredColumns = useMemo(() => {
if (!searchQuery.trim()) return columns;
const query = searchQuery.toLowerCase();
return columns.filter((col) => {
const columnName = (col.columnName || "").toLowerCase();
const columnLabel = (col.columnLabel || "").toLowerCase();
const tableName = (col.tableName || "").toLowerCase();
const tableLabel = (col.tableLabel || "").toLowerCase();
return columnName.includes(query) ||
columnLabel.includes(query) ||
tableName.includes(query) ||
tableLabel.includes(query);
});
}, [columns, searchQuery]);
useEffect(() => {
if (menuObjid) {
loadCategoryColumnsByMenu();
} else if (tableName) {
// menuObjid가 없으면 tableName 기반으로 조회
loadCategoryColumnsByTable();
} else {
console.warn("⚠️ menuObjid와 tableName 모두 없어서 카테고리 컬럼을 로드할 수 없습니다");
setColumns([]);
}
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
loadCategoryColumnsByMenu();
}, [menuObjid, tableName]);
// tableName 기반으로 카테고리 컬럼 조회
@ -126,10 +139,13 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
const loadCategoryColumnsByMenu = async () => {
setIsLoading(true);
try {
console.log("🔍 형제 메뉴의 카테고리 컬럼 조회 시작", { menuObjid });
console.log("🔍 회사 기준 카테고리 컬럼 조회 시작", { menuObjid });
// 새 API: 형제 메뉴들의 카테고리 컬럼 조회
const response = await apiClient.get(`/table-management/menu/${menuObjid}/category-columns`);
// 회사 기준 카테고리 컬럼 조회 (menuObjid는 선택사항)
const url = menuObjid
? `/table-management/menu/${menuObjid}/category-columns`
: `/table-management/category-columns`;
const response = await apiClient.get(url);
console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", {
menuObjid,
@ -242,8 +258,33 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 검색 입력 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="컬럼 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 pr-8 text-xs"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="space-y-2">
{columns.map((column) => {
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{filteredColumns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
return (

View File

@ -251,29 +251,57 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
loadTables();
}, []);
// 현재 테이블과 연관된 테이블 목록 로드 (엔티티 관계 기반)
// 연관 테이블 목록 로드 (엔티티 관계 기반)
// 1. 화면 메인 테이블이 있으면: 그 테이블을 참조하는 테이블 (자식 테이블)
// 2. 저장 테이블이 선택되었으면: 그 테이블을 참조하는 테이블도 추가
useEffect(() => {
const loadRelatedTables = async () => {
if (!currentTableName) {
// 화면 메인 테이블 또는 저장 테이블 중 하나라도 있어야 함
const baseTable = currentTableName || config.mainTableName;
if (!baseTable) {
setRelatedTables([]);
return;
}
setLoadingRelations(true);
try {
// column_labels에서 현재 테이블을 reference_table로 참조하는 테이블 찾기
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`);
const allRelations: TableRelation[] = [];
if (response.data.success && response.data.data) {
const relations: TableRelation[] = response.data.data.map((rel: any) => ({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreignKeyColumn: rel.columnName || rel.column_name, // FK 컬럼
referenceColumn: rel.referenceColumn || rel.reference_column || "id", // PK 컬럼
}));
setRelatedTables(relations);
// 1. 화면 메인 테이블을 참조하는 테이블 조회 (자식 테이블)
if (currentTableName) {
const response = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`);
if (response.data.success && response.data.data) {
const relations: TableRelation[] = response.data.data.map((rel: any) => ({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreignKeyColumn: rel.columnName || rel.column_name, // FK 컬럼 (자식 테이블의)
referenceColumn: rel.referenceColumn || rel.reference_column || "id", // PK 컬럼 (부모 테이블의)
}));
allRelations.push(...relations);
}
}
// 2. 저장 테이블이 화면 메인 테이블과 다르면, 저장 테이블을 참조하는 테이블도 조회
if (config.mainTableName && config.mainTableName !== currentTableName) {
const response2 = await apiClient.get(`/table-management/columns/${config.mainTableName}/referenced-by`);
if (response2.data.success && response2.data.data) {
const relations2: TableRelation[] = response2.data.data.map((rel: any) => ({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreignKeyColumn: rel.columnName || rel.column_name,
referenceColumn: rel.referenceColumn || rel.reference_column || "id",
}));
// 중복 제거 후 추가
relations2.forEach(rel => {
if (!allRelations.some(r => r.tableName === rel.tableName)) {
allRelations.push(rel);
}
});
}
}
setRelatedTables(allRelations);
} catch (error) {
console.error("연관 테이블 로드 실패:", error);
setRelatedTables([]);
@ -282,7 +310,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
}
};
loadRelatedTables();
}, [currentTableName]);
}, [currentTableName, config.mainTableName]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
@ -806,16 +834,17 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
</PopoverContent>
</Popover>
{/* FK 직접 입력 (연관 테이블이 아닌 경우만) */}
{config.useCustomTable && config.mainTableName &&
{/* FK 직접 입력 - 화면 메인 테이블이 있고 연관 테이블이 아닌 경우만 표시 */}
{/* 화면 메인 테이블이 없으면 FK 설정 불필요 (독립 저장) */}
{config.useCustomTable && config.mainTableName && currentTableName &&
!relatedTables.some(r => r.tableName === config.mainTableName) && (
<div className="space-y-2 rounded border border-amber-200 bg-amber-50 p-2">
<p className="text-[10px] text-amber-700">
. FK .
({currentTableName}) . FK .
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]">FK </Label>
<Label className="text-[10px]">FK ( )</Label>
<Input
value={config.foreignKeyColumn || ""}
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
@ -824,7 +853,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]">PK </Label>
<Label className="text-[10px]">PK ( )</Label>
<Input
value={config.foreignKeySourceColumn || "id"}
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
@ -835,26 +864,31 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
</div>
</div>
)}
{/* 화면 메인 테이블이 없을 때 안내 */}
{config.useCustomTable && config.mainTableName && !currentTableName && (
<div className="rounded border border-blue-200 bg-blue-50 p-2">
<p className="text-[10px] text-blue-700">
모드: 화면 .
</p>
</div>
)}
</div>
<Separator />
{/* 현재 화면 정보 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{currentTableName ? (
<div className="rounded border border-blue-200 bg-blue-50 p-2">
<p className="text-xs text-blue-700 font-medium">{currentTableName}</p>
<p className="text-[10px] text-blue-500">
{/* 현재 화면 정보 (메인 테이블이 설정된 경우에만 표시) */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
<div className="rounded border border-gray-200 bg-gray-50 p-2">
<p className="text-xs text-gray-700 font-medium">{currentTableName}</p>
<p className="text-[10px] text-gray-500">
{currentTableColumns.length} / {entityColumns.length}
</p>
</div>
) : (
<div className="rounded border border-amber-200 bg-amber-50 p-2">
<p className="text-[10px] text-amber-600"> </p>
</div>
)}
</div>
</div>
)}
{/* 모달 모드: 엔티티 컬럼 선택 */}
{isModalMode && (
@ -870,7 +904,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
<Select
value={config.dataSource?.foreignKey || ""}
onValueChange={handleEntityColumnSelect}
disabled={!currentTableName}
disabled={!targetTableForColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="엔티티 컬럼 선택" />
@ -891,7 +925,11 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
) : (
<div className="rounded border border-gray-200 bg-gray-50 p-2">
<p className="text-[10px] text-gray-500">
{loadingColumns ? "로딩 중..." : "엔티티 타입 컬럼이 없습니다"}
{loadingColumns
? "로딩 중..."
: !targetTableForColumns
? "저장 테이블을 먼저 선택하세요"
: "엔티티 타입 컬럼이 없습니다"}
</p>
</div>
)}

View File

@ -173,7 +173,29 @@ export async function resetSequence(ruleId: string): Promise<ApiResponse<void>>
}
}
// ====== 테스트용 API (menu_objid 없는 방식) ======
// ====== 테스트용 API (numbering_rules_test 테이블 사용) ======
/**
* []
* numbering_rules_test
* @param menuObjid OBJID () -
*/
export async function getNumberingRulesFromTest(
menuObjid?: number
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try {
const url = menuObjid
? `/numbering-rules/test/list/${menuObjid}`
: "/numbering-rules/test/list";
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "테스트 규칙 목록 조회 실패",
};
}
}
/**
* [] +