Compare commits

...

5 Commits

Author SHA1 Message Date
kjs a828f54663 feat: 카테고리 배지 없음 옵션 추가
- 카테고리 색상 설정 시 '배지 없음' 옵션 추가
- color='none'일 때 배지 대신 일반 텍스트로 표시
- CategoryValueEditDialog, CategoryValueAddDialog에 배지 없음 버튼 추가
- InteractiveDataTable, TableListComponent에서 배지 없음 처리
- CategoryValueManager에서 배지 없음 표시 추가
- 기본 색상을 배지 없음으로 변경
2025-11-13 15:24:31 +09:00
kjs f73f788b0a fix: 카테고리 컬럼 중복 데이터 문제 해결
- table_column_category_values JOIN 시 회사 데이터만 사용하도록 수정
- 공통 데이터(company_code='*') 사용 금지
- is_active=true 필터 추가로 활성화된 카테고리만 조회
- entityJoinService.ts의 buildJoinQuery 및 buildCountQuery 수정
- 품목 정보 테이블 등에서 발생하던 2배 중복 문제 해결
2025-11-13 15:16:36 +09:00
kjs 36bff64145 feat: 카테고리 컬럼 메뉴별 매핑 기능 구현
- category_column_mapping 테이블 생성 (마이그레이션 054)
- 테이블 타입 관리에서 2레벨 메뉴 선택 기능 추가
- 카테고리 컬럼 조회 시 현재 메뉴 및 상위 메뉴 매핑 자동 조회
- 캐시 무효화 로직 개선
- 메뉴별 카테고리 설정 저장 및 불러오기 기능 구현
2025-11-13 14:41:24 +09:00
kjs 9dc8a51f4c fix: 메뉴 URL 제거 시 화면 할당도 함께 해제
## 문제점
- menu_url을 빈 값으로 저장해도 screen_menu_assignments 테이블의
  화면 할당(is_active='Y')이 남아있어 메뉴 클릭 시 여전히 화면이 열림
- AppLayout의 handleMenuClick이 우선적으로 screen_menu_assignments를
  조회하므로 menu_url보다 화면 할당이 우선 적용됨

## 해결방법

### updateMenu (메뉴 수정)
- menu_url이 null/빈값일 때 screen_menu_assignments의 is_active를 'N'으로 업데이트
- 화면 할당과 menu_url을 동기화하여 완전한 할당 해제

### saveMenu (메뉴 생성)
- 기존과 동일하게 menu_url이 없으면 screen_code를 null로 설정
- INSERT 시 screen_code 컬럼을 명시적으로 포함

## 메뉴 클릭 동작 순서
1. screen_menu_assignments 조회 (우선순위)
2. is_active='N'이면 할당된 화면 없음으로 간주
3. menu_url이 있으면 해당 URL로 이동
4. 둘 다 없으면 "연결된 페이지가 없습니다" 경고

이제 메뉴에서 URL을 제거하면 화면 할당도 완전히 해제됩니다.
2025-11-13 12:27:36 +09:00
kjs 3ddca95af5 feat: 메뉴 관리에서 화면 할당 해제 기능 추가
## 문제점
- URL 직접 입력 모드에서 빈 값으로 저장 시 menu_url은 비워지지만
- screen_code는 기존 값이 남아있어 화면 할당이 해제되지 않음

## 해결방법

### 백엔드 (adminController.ts)
- updateMenu: menu_url이 비어있으면 screen_code도 null로 자동 설정
- 로직: menuUrl ? screenCode : null

### 프론트엔드 (MenuFormModal.tsx, menu.ts)
- 화면 선택 시 screenCode도 함께 formData에 저장
- URL 타입 변경 시 screenCode 초기화
- MenuFormData 인터페이스에 screenCode 필드 추가

## 동작 방식
1. 화면 할당: menuUrl + screenCode 함께 저장
2. URL 직접 입력: menuUrl만 저장, screenCode는 undefined
3. 빈 값 저장: menuUrl = null, screenCode = null (자동)

이제 메뉴에서 화면 할당을 완전히 해제할 수 있습니다.
2025-11-13 12:22:33 +09:00
18 changed files with 998 additions and 177 deletions

View File

@ -1127,12 +1127,16 @@ export async function saveMenu(
const objid = Date.now(); // 고유 ID 생성
const companyCode = requestCompanyCode || userCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
const [savedMenu] = await query<any>(
`INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, regdate, status,
system_name, company_code, lang_key, lang_key_desc
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
system_name, company_code, lang_key, lang_key_desc, screen_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *`,
[
objid,
@ -1141,7 +1145,7 @@ export async function saveMenu(
menuData.menuNameKor,
menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null,
menuData.menuUrl || null,
menuUrl,
menuData.menuDesc || null,
req.user?.userId || "admin",
new Date(),
@ -1150,6 +1154,7 @@ export async function saveMenu(
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
screenCode,
]
);
@ -1274,6 +1279,10 @@ export async function updateMenu(
const companyCode = requestCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
// Raw Query를 사용한 메뉴 수정
const [updatedMenu] = await query<any>(
`UPDATE menu_info SET
@ -1288,8 +1297,9 @@ export async function updateMenu(
system_name = $9,
company_code = $10,
lang_key = $11,
lang_key_desc = $12
WHERE objid = $13
lang_key_desc = $12,
screen_code = $13
WHERE objid = $14
RETURNING *`,
[
menuData.menuType ? Number(menuData.menuType) : null,
@ -1297,17 +1307,29 @@ export async function updateMenu(
menuData.menuNameKor,
menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null,
menuData.menuUrl || null,
menuUrl,
menuData.menuDesc || null,
menuData.status || "active",
menuData.systemName || null,
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
screenCode,
Number(menuId),
]
);
// menu_url이 비어있으면 화면 할당도 해제 (screen_menu_assignments의 is_active를 'N'으로)
if (!menuUrl) {
await query(
`UPDATE screen_menu_assignments
SET is_active = 'N', updated_date = NOW()
WHERE menu_objid = $1 AND company_code = $2`,
[Number(menuId), companyCode]
);
logger.info("화면 할당 비활성화", { menuId, companyCode });
}
logger.info("메뉴 수정 성공", { updatedMenu });
const response: ApiResponse<any> = {

View File

@ -471,3 +471,33 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
}
};
/**
* 2
*
* GET /api/categories/second-level-menus
*
*
* 2
*/
export const getSecondLevelMenus = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
logger.info("2레벨 메뉴 목록 조회", { companyCode });
const menus = await tableCategoryValueService.getSecondLevelMenus(companyCode);
return res.json({
success: true,
data: menus,
});
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "2레벨 메뉴 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};

View File

@ -1657,37 +1657,108 @@ export async function getCategoryColumnsByMenu(
return;
}
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode });
const columnsQuery = `
SELECT
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.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
logger.info("🔍 카테고리 컬럼 쿼리 실행 중...");
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length });
// 3. 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;
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
FROM menu_info
WHERE objid = $1
UNION ALL
--
SELECT m.objid, m.parent_obj_id, m.menu_type
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
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
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"
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.table_name = ANY($1)
AND ccm.company_code = $2
AND ccm.menu_objid = ANY($3)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
} else {
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
const columnsQuery = `
SELECT
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.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length

View File

@ -11,6 +11,7 @@ import {
createColumnMapping,
getLogicalColumns,
deleteColumnMapping,
getSecondLevelMenus,
} from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware";
@ -44,6 +45,9 @@ router.post("/values/reorder", reorderCategoryValues);
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
// ================================================
// 2레벨 메뉴 목록 조회 (메뉴 선택용)
router.get("/second-level-menus", getSecondLevelMenus);
// 컬럼 매핑 조회
router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping);

View File

@ -223,12 +223,14 @@ export class EntityJoinService {
const aliasMap = new Map<string, string>();
const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
)
) {
acc.push(config);
@ -237,7 +239,7 @@ export class EntityJoinService {
}, [] as EntityJoinConfig[]);
logger.info(
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블`
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합`
);
uniqueReferenceTableConfigs.forEach((config) => {
@ -250,13 +252,16 @@ export class EntityJoinService {
counter++;
}
usedAliasesForColumns.add(alias);
aliasMap.set(config.referenceTable, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}${alias}`);
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`);
});
const joinColumns = joinConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
const displayColumns = config.displayColumns || [
config.displayColumn,
];
@ -346,14 +351,16 @@ export class EntityJoinService {
// FROM 절 (메인 테이블)
const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거)
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`;
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
@ -538,12 +545,13 @@ export class EntityJoinService {
const aliasMap = new Map<string, string>();
const usedAliases = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
)
) {
acc.push(config);
@ -561,13 +569,22 @@ export class EntityJoinService {
counter++;
}
usedAliases.add(alias);
aliasMap.set(config.referenceTable, alias);
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
});
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");

View File

@ -1514,6 +1514,7 @@ export class ScreenManagementService {
throw new Error("이미 할당된 화면입니다.");
}
// screen_menu_assignments에 할당 추가
await query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, created_by
@ -1526,6 +1527,40 @@ export class ScreenManagementService {
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,
});
}
}
/**
@ -1589,11 +1624,26 @@ export class ScreenManagementService {
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,
});
}
// ========================================

View File

@ -973,6 +973,96 @@ class TableCategoryValueService {
return data;
}
}
/**
* 2
*
*
*
* @param companyCode -
* @returns 2
*/
async getSecondLevelMenus(companyCode: string): Promise<any[]> {
const pool = getPool();
try {
logger.info("2레벨 메뉴 목록 조회", { companyCode });
// menu_info 테이블에 company_code 컬럼이 있는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'menu_info' AND column_name = 'company_code'
`;
const columnCheck = await pool.query(columnCheckQuery);
const hasCompanyCode = columnCheck.rows.length > 0;
logger.info("menu_info 테이블 company_code 컬럼 존재 여부", { hasCompanyCode });
let query: string;
let params: any[];
if (!hasCompanyCode) {
// company_code 컬럼이 없는 경우: 모든 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else if (companyCode === "*") {
// 최고 관리자: 모든 회사의 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else {
// 일반 회사: 자신의 회사 메뉴만 조회 (공통 메뉴 제외)
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
AND m1.company_code = $1
ORDER BY m0.seq, m1.seq
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info(`2레벨 메뉴 ${result.rows.length}개 조회 완료`, { companyCode });
return result.rows;
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new TableCategoryValueService();

View File

@ -249,21 +249,78 @@ export class TableManagementService {
[tableName, size, offset]
);
// 🆕 category_column_mapping 조회
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries())
});
}
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
}));
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
const baseColumn = {
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (column.inputType === "category" && categoryMappings.has(column.columnName)) {
const menus = categoryMappings.get(column.columnName);
logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus });
return {
...baseColumn,
categoryMenus: menus,
};
}
return baseColumn;
});
const totalPages = Math.ceil(total / size);
@ -429,7 +486,7 @@ export class TableManagementService {
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) {
logger.error(
@ -484,7 +541,7 @@ export class TableManagementService {
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`);
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
} catch (error) {
logger.error(
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
@ -2347,11 +2404,16 @@ export class TableManagementService {
whereClause
);
// ⚠️ SQL 쿼리 로깅 (디버깅용)
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
// 병렬 실행
const [dataResult, countResult] = await Promise.all([
query(dataQuery),
query(countQuery),
]);
logger.info(`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`);
const data = Array.isArray(dataResult) ? dataResult : [];
const total =
@ -3152,19 +3214,83 @@ export class TableManagementService {
[tableName, companyCode]
);
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
}));
// category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries())
});
} else {
logger.warn("category_column_mapping 테이블이 존재하지 않음");
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (col.inputType === "category" && categoryMappings.has(col.columnName)) {
const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus });
return {
...baseInfo,
categoryMenus: menus,
};
}
if (col.inputType === "category") {
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
}
return baseInfo;
});
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`

View File

@ -17,6 +17,7 @@ import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl";
import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@ -54,6 +55,14 @@ interface ColumnTypeInfo {
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
}
interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
export default function TableManagementPage() {
@ -89,6 +98,9 @@ export default function TableManagementPage() {
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
// 🆕 Category 타입용: 2레벨 메뉴 목록
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
@ -224,6 +236,22 @@ export default function TableManagementPage() {
}
};
// 🆕 2레벨 메뉴 목록 로드
const loadSecondLevelMenus = async () => {
try {
const response = await getSecondLevelMenus();
if (response.success && response.data) {
setSecondLevelMenus(response.data);
} else {
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
}
} catch (error) {
console.error("❌ 2레벨 메뉴 로드 에러:", error);
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
}
};
// 테이블 목록 로드
const loadTables = async () => {
setLoading(true);
@ -257,10 +285,17 @@ export default function TableManagementPage() {
if (response.data.success) {
const data = response.data.data;
console.log("📥 원본 API 응답:", {
hasColumns: !!(data.columns || data),
firstColumn: (data.columns || data)[0],
statusColumn: (data.columns || data).find((col: any) => col.columnName === "status"),
});
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => ({
...col,
inputType: col.inputType || "text", // 기본값: text
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
}));
if (page === 1) {
@ -438,12 +473,72 @@ export default function TableManagementPage() {
// console.log("저장할 컬럼 설정:", columnSetting);
console.log("💾 저장할 컬럼 정보:", {
columnName: column.columnName,
inputType: column.inputType,
categoryMenus: column.categoryMenus,
hasCategoryMenus: !!column.categoryMenus,
categoryMenusLength: column.categoryMenus?.length || 0,
});
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting,
]);
if (response.data.success) {
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 생성
console.log("🔍 카테고리 조건 체크:", {
isCategory: column.inputType === "category",
hasCategoryMenus: !!column.categoryMenus,
length: column.categoryMenus?.length || 0,
});
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) {
console.log("📥 카테고리 메뉴 매핑 시작:", {
columnName: column.columnName,
categoryMenus: column.categoryMenus,
count: column.categoryMenus.length,
});
let successCount = 0;
let failCount = 0;
for (const menuObjid of column.categoryMenus) {
try {
const mappingResponse = await createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.columnName,
physicalColumnName: column.columnName,
menuObjid,
description: `${column.displayName} (메뉴별 카테고리)`,
});
if (mappingResponse.success) {
successCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
failCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
failCount++;
}
}
if (successCount > 0 && failCount === 0) {
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
} else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
} else if (failCount > 0) {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
}
} else {
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
}
// 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col)));
@ -501,14 +596,78 @@ export default function TableManagementPage() {
);
if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성
const categoryColumns = columns.filter(
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
);
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length,
categoryColumns: categoryColumns.length,
categoryColumnsData: categoryColumns.map((col) => ({
columnName: col.columnName,
categoryMenus: col.categoryMenus,
})),
});
if (categoryColumns.length > 0) {
let totalSuccessCount = 0;
let totalFailCount = 0;
for (const column of categoryColumns) {
for (const menuObjid of column.categoryMenus!) {
try {
console.log("🔄 매핑 API 호출:", {
tableName: selectedTable,
columnName: column.columnName,
menuObjid,
});
const mappingResponse = await createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.columnName,
physicalColumnName: column.columnName,
menuObjid,
description: `${column.displayName} (메뉴별 카테고리)`,
});
console.log("✅ 매핑 API 응답:", mappingResponse);
if (mappingResponse.success) {
totalSuccessCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
totalFailCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
}
}
}
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
if (totalSuccessCount > 0) {
toast.success(
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
);
} else if (totalFailCount > 0) {
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
} else {
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
}
} else {
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
}
// 저장 성공 후 원본 데이터 업데이트
setOriginalColumns([...columns]);
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
// 테이블 목록 새로고침 (라벨 변경 반영)
loadTables();
// 저장 후 데이터 확인을 위해 다시 로드
// 저장 후 데이터 다시 로드
setTimeout(() => {
loadColumnTypes(selectedTable, 1, pageSize);
}, 1000);
@ -539,6 +698,7 @@ export default function TableManagementPage() {
useEffect(() => {
loadTables();
loadCommonCodeCategories();
loadSecondLevelMenus();
}, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@ -1023,10 +1183,61 @@ export default function TableManagementPage() {
</SelectContent>
</Select>
)}
{/* 입력 타입이 'category'인 경우 안내 메시지 */}
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
{column.inputType === "category" && (
<div className="flex items-center h-8 text-xs text-muted-foreground">
<div className="space-y-2">
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
setColumns((prev) =>
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col
)
);
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
);
})
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)}
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}

View File

@ -161,6 +161,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: screen.screenCode, // 화면 코드도 함께 저장
}));
// console.log("🖥️ 화면 선택 완료:", {
@ -207,10 +208,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
if (type === "direct") {
// 직접 입력 모드로 변경 시 선택된 화면 초기화
setSelectedScreen(null);
// URL 필드 초기화 (사용자가 직접 입력할 수 있도록)
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined, // 화면 코드도 함께 초기화
}));
} else {
// 화면 할당 모드로 변경 시
@ -230,12 +232,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
}));
} else {
// 선택된 화면이 없으면 URL 초기화
// 선택된 화면이 없으면 URL과 screenCode 초기화
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
}

View File

@ -575,6 +575,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(true);
try {
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
@ -582,6 +584,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
});
console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName,
dataLength: result.data.length,
total: result.total,
page: result.page
});
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
@ -1952,7 +1961,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
switch (actualWebType) {
case "category": {
// 카테고리 타입: 배지로 표시
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return "";
const mapping = categoryMappings[column.columnName];
@ -1962,6 +1971,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{

View File

@ -21,15 +21,22 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { createColumnMapping } from "@/lib/api/tableCategoryValue";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { apiClient } from "@/lib/api/client";
interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
interface AddCategoryColumnDialogProps {
tableName: string;
menuObjid: number;
menuName: string;
onSuccess: () => void;
}
@ -37,27 +44,31 @@ interface AddCategoryColumnDialogProps {
*
*
*
*
* 2
*/
export function AddCategoryColumnDialog({
tableName,
menuObjid,
menuName,
onSuccess,
}: AddCategoryColumnDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
const [description, setDescription] = useState("");
// 테이블의 실제 컬럼 목록 조회
// 다이얼로그 열릴 때 데이터 로드
useEffect(() => {
if (open) {
loadPhysicalColumns();
loadSecondLevelMenus();
}
}, [open, tableName]);
// 테이블의 실제 컬럼 목록 조회
const loadPhysicalColumns = async () => {
try {
const response = await tableManagementApi.getTableColumns(tableName);
@ -70,6 +81,32 @@ export function AddCategoryColumnDialog({
}
};
// 2레벨 메뉴 목록 조회
const loadSecondLevelMenus = async () => {
try {
const response = await apiClient.get<{
success: boolean;
data: SecondLevelMenu[];
}>("table-categories/second-level-menus");
if (response.data.success && response.data.data) {
setSecondLevelMenus(response.data.data);
}
} catch (error) {
console.error("2레벨 메뉴 목록 조회 실패:", error);
toast.error("메뉴 목록을 불러올 수 없습니다");
}
};
// 메뉴 선택/해제
const toggleMenu = (menuObjid: number) => {
setSelectedMenus((prev) =>
prev.includes(menuObjid)
? prev.filter((id) => id !== menuObjid)
: [...prev, menuObjid]
);
};
const handleSave = async () => {
// 입력 검증
if (!logicalColumnName.trim()) {
@ -82,24 +119,42 @@ export function AddCategoryColumnDialog({
return;
}
if (selectedMenus.length === 0) {
toast.error("최소 하나 이상의 메뉴를 선택해주세요");
return;
}
setLoading(true);
try {
const response = await createColumnMapping({
tableName,
logicalColumnName: logicalColumnName.trim(),
physicalColumnName,
menuObjid,
description: description.trim() || undefined,
});
// 선택된 각 메뉴에 대해 매핑 생성
const promises = selectedMenus.map((menuObjid) =>
createColumnMapping({
tableName,
logicalColumnName: logicalColumnName.trim(),
physicalColumnName,
menuObjid,
description: description.trim() || undefined,
})
);
if (response.success) {
toast.success("논리적 컬럼이 추가되었습니다");
const results = await Promise.all(promises);
// 모든 요청이 성공했는지 확인
const failedCount = results.filter((r) => !r.success).length;
if (failedCount === 0) {
toast.success(`논리적 컬럼이 ${selectedMenus.length}개 메뉴에 추가되었습니다`);
setOpen(false);
resetForm();
onSuccess();
} else if (failedCount < results.length) {
toast.warning(
`${results.length - failedCount}개 메뉴에 추가 성공, ${failedCount}개 실패`
);
onSuccess();
} else {
toast.error(response.error || "컬럼 매핑 생성에 실패했습니다");
toast.error("모든 메뉴에 대한 매핑 생성에 실패했습니다");
}
} catch (error: any) {
console.error("컬럼 매핑 생성 실패:", error);
@ -113,6 +168,7 @@ export function AddCategoryColumnDialog({
setLogicalColumnName("");
setPhysicalColumnName("");
setDescription("");
setSelectedMenus([]);
};
return (
@ -130,21 +186,11 @@ export function AddCategoryColumnDialog({
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
2
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 적용 메뉴 (읽기 전용) */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={menuName}
disabled
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 실제 컬럼 선택 */}
<div>
<Label className="text-xs sm:text-sm">
@ -179,10 +225,47 @@ export function AddCategoryColumnDialog({
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 적용할 2레벨 메뉴 선택 (체크박스) */}
<div>
<Label className="text-xs sm:text-sm">
(2) *
</Label>
<div className="border rounded-lg p-3 sm:p-4 space-y-2 max-h-48 overflow-y-auto mt-2">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground"> ...</p>
) : (
secondLevelMenus.map((menu) => (
<div key={menu.menuObjid} className="flex items-center gap-2">
<Checkbox
id={`menu-${menu.menuObjid}`}
checked={selectedMenus.includes(menu.menuObjid)}
onCheckedChange={() => toggleMenu(menu.menuObjid)}
className="h-4 w-4"
/>
<label
htmlFor={`menu-${menu.menuObjid}`}
className="text-xs sm:text-sm cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
))
)}
</div>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
{selectedMenus.length > 0 && (
<p className="text-primary mt-1 text-[10px] sm:text-xs">
{selectedMenus.length}
</p>
)}
</div>
{/* 설명 (선택사항) */}
<div>
<Label className="text-xs sm:text-sm"></Label>
@ -207,7 +290,7 @@ export function AddCategoryColumnDialog({
</Button>
<Button
onClick={handleSave}
disabled={!logicalColumnName || !physicalColumnName || loading}
disabled={!logicalColumnName || !physicalColumnName || selectedMenus.length === 0 || loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "추가 중..." : "추가"}

View File

@ -50,7 +50,7 @@ export const CategoryValueAddDialog: React.FC<
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState("#3b82f6");
const [color, setColor] = useState("none");
// 라벨에서 코드 자동 생성
const generateCode = (label: string): string => {
@ -91,7 +91,7 @@ export const CategoryValueAddDialog: React.FC<
// 초기화
setValueLabel("");
setDescription("");
setColor("#3b82f6");
setColor("none");
};
return (
@ -123,24 +123,41 @@ export const CategoryValueAddDialog: React.FC<
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
<div className="mt-1.5 space-y-2">
<div className="flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
<button
type="button"
onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div>
</div>

View File

@ -51,12 +51,12 @@ export const CategoryValueEditDialog: React.FC<
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel);
const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "#3b82f6");
const [color, setColor] = useState(value.color || "none");
useEffect(() => {
setValueLabel(value.valueLabel);
setDescription(value.description || "");
setColor(value.color || "#3b82f6");
setColor(value.color || "none");
}, [value]);
const handleSubmit = () => {
@ -100,24 +100,41 @@ export const CategoryValueEditDialog: React.FC<
<div>
<Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
<div className="mt-1.5 space-y-2">
<div className="flex items-center gap-3">
<div className="grid grid-cols-9 gap-2">
{DEFAULT_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`h-7 w-7 rounded-md border-2 transition-all ${
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
<button
type="button"
onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div>
</div>

View File

@ -349,13 +349,18 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
/>
<div className="flex flex-1 items-center gap-2">
{/* 색상 표시 (앞쪽으로 이동) */}
{value.color && (
{/* 색상 표시 (배지 없음 옵션 지원) */}
{value.color && value.color !== "none" && (
<div
className="h-4 w-4 rounded-full border flex-shrink-0"
style={{ backgroundColor: value.color }}
/>
)}
{value.color === "none" && (
<span className="text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded">
</span>
)}
{/* 라벨 */}
<span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>

View File

@ -53,6 +53,7 @@ export interface MenuFormData {
status: string;
companyCode: string;
langKey?: string; // 다국어 키 추가
screenCode?: string; // 화면 코드 추가
}
export interface LangKey {

View File

@ -237,3 +237,28 @@ export async function deleteColumnMapping(mappingId: number) {
}
}
/**
* 2
*
*
*
* @returns 2
*/
export async function getSecondLevelMenus() {
try {
const response = await apiClient.get<{
success: boolean;
data: Array<{
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}>;
}>("/table-categories/second-level-menus");
return response.data;
} catch (error: any) {
console.error("2레벨 메뉴 목록 조회 실패:", error);
return { success: false, error: error.message, data: [] };
}
}

View File

@ -802,6 +802,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// ========================================
const fetchTableDataInternal = useCallback(async () => {
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
tableName: tableConfig.selectedTable,
isDesignMode,
currentPage,
});
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
setTotalPages(0);
@ -809,11 +815,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 테이블명 확인 로그 (개발 시에만)
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
// console.log("🔍 전체 tableConfig:", tableConfig);
setLoading(true);
setError(null);
@ -834,6 +835,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
console.log("🔍 [TableList] API 호출 시작", {
tableName: tableConfig.selectedTable,
page,
pageSize,
sortBy,
sortOrder,
});
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
@ -845,6 +854,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
});
// 실제 데이터의 item_number만 추출하여 중복 확인
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
const uniqueItemNumbers = [...new Set(itemNumbers)];
console.log("✅ [TableList] API 응답 받음");
console.log(` - dataLength: ${response.data?.length || 0}`);
console.log(` - total: ${response.total}`);
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
@ -1354,28 +1374,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 카테고리 타입: 배지로 표시
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (inputType === "category") {
if (!value) return "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// console.log(`🎨 [카테고리 배지] ${column.columnName}:`, {
// value,
// stringValue: String(value),
// mapping,
// categoryData,
// hasMapping: !!mapping,
// hasCategoryData: !!categoryData,
// allCategoryMappings: categoryMappings, // 전체 매핑 확인
// categoryMappingsKeys: Object.keys(categoryMappings),
// });
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
const { Badge } = require("@/components/ui/badge");
return (
<Badge
@ -1716,6 +1730,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => {
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
isDesignMode,
tableName: tableConfig.selectedTable,
currentPage,
sortColumn,
sortDirection,
});
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
@ -1730,7 +1752,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
fetchTableDataDebounced,
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]);
useEffect(() => {
@ -2157,9 +2179,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
</td>
</tr>
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
) : (() => {
console.log("🔍 [TableList] 렌더링 조건 체크", {
groupByColumns: groupByColumns.length,
groupedDataLength: groupedData.length,
willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
dataLength: data.length,
});
return groupByColumns.length > 0 && groupedData.length > 0;
})() ? (
// 그룹화된 렌더링
groupedData.map((group) => {
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
<React.Fragment key={group.groupKey}>
@ -2252,7 +2283,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})
) : (
// 일반 렌더링 (그룹 없음)
data.map((row, index) => (
(() => {
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
return data;
})().map((row, index) => (
<tr
key={index}
className={cn(