Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
ec26aa1bac
|
|
@ -1127,12 +1127,16 @@ export async function saveMenu(
|
||||||
const objid = Date.now(); // 고유 ID 생성
|
const objid = Date.now(); // 고유 ID 생성
|
||||||
const companyCode = requestCompanyCode || userCompanyCode;
|
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>(
|
const [savedMenu] = await query<any>(
|
||||||
`INSERT INTO menu_info (
|
`INSERT INTO menu_info (
|
||||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||||
seq, menu_url, menu_desc, writer, regdate, status,
|
seq, menu_url, menu_desc, writer, regdate, status,
|
||||||
system_name, company_code, lang_key, lang_key_desc
|
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)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
objid,
|
objid,
|
||||||
|
|
@ -1141,7 +1145,7 @@ export async function saveMenu(
|
||||||
menuData.menuNameKor,
|
menuData.menuNameKor,
|
||||||
menuData.menuNameEng || null,
|
menuData.menuNameEng || null,
|
||||||
menuData.seq ? Number(menuData.seq) : null,
|
menuData.seq ? Number(menuData.seq) : null,
|
||||||
menuData.menuUrl || null,
|
menuUrl,
|
||||||
menuData.menuDesc || null,
|
menuData.menuDesc || null,
|
||||||
req.user?.userId || "admin",
|
req.user?.userId || "admin",
|
||||||
new Date(),
|
new Date(),
|
||||||
|
|
@ -1150,6 +1154,7 @@ export async function saveMenu(
|
||||||
companyCode,
|
companyCode,
|
||||||
menuData.langKey || null,
|
menuData.langKey || null,
|
||||||
menuData.langKeyDesc || null,
|
menuData.langKeyDesc || null,
|
||||||
|
screenCode,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1274,6 +1279,10 @@ export async function updateMenu(
|
||||||
|
|
||||||
const companyCode = requestCompanyCode;
|
const companyCode = requestCompanyCode;
|
||||||
|
|
||||||
|
// menu_url이 비어있으면 screen_code도 null로 설정
|
||||||
|
const menuUrl = menuData.menuUrl || null;
|
||||||
|
const screenCode = menuUrl ? menuData.screenCode || null : null;
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 수정
|
// Raw Query를 사용한 메뉴 수정
|
||||||
const [updatedMenu] = await query<any>(
|
const [updatedMenu] = await query<any>(
|
||||||
`UPDATE menu_info SET
|
`UPDATE menu_info SET
|
||||||
|
|
@ -1288,8 +1297,9 @@ export async function updateMenu(
|
||||||
system_name = $9,
|
system_name = $9,
|
||||||
company_code = $10,
|
company_code = $10,
|
||||||
lang_key = $11,
|
lang_key = $11,
|
||||||
lang_key_desc = $12
|
lang_key_desc = $12,
|
||||||
WHERE objid = $13
|
screen_code = $13
|
||||||
|
WHERE objid = $14
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
menuData.menuType ? Number(menuData.menuType) : null,
|
menuData.menuType ? Number(menuData.menuType) : null,
|
||||||
|
|
@ -1297,17 +1307,29 @@ export async function updateMenu(
|
||||||
menuData.menuNameKor,
|
menuData.menuNameKor,
|
||||||
menuData.menuNameEng || null,
|
menuData.menuNameEng || null,
|
||||||
menuData.seq ? Number(menuData.seq) : null,
|
menuData.seq ? Number(menuData.seq) : null,
|
||||||
menuData.menuUrl || null,
|
menuUrl,
|
||||||
menuData.menuDesc || null,
|
menuData.menuDesc || null,
|
||||||
menuData.status || "active",
|
menuData.status || "active",
|
||||||
menuData.systemName || null,
|
menuData.systemName || null,
|
||||||
companyCode,
|
companyCode,
|
||||||
menuData.langKey || null,
|
menuData.langKey || null,
|
||||||
menuData.langKeyDesc || null,
|
menuData.langKeyDesc || null,
|
||||||
|
screenCode,
|
||||||
Number(menuId),
|
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 });
|
logger.info("메뉴 수정 성공", { updatedMenu });
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
|
|
@ -2595,6 +2617,72 @@ export const createCompany = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/companies/:companyCode
|
||||||
|
* 회사 정보 조회 API
|
||||||
|
*/
|
||||||
|
export const getCompanyByCode = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.params;
|
||||||
|
|
||||||
|
logger.info("회사 정보 조회 요청", {
|
||||||
|
companyCode,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Raw Query로 회사 정보 조회
|
||||||
|
const company = await queryOne<any>(
|
||||||
|
`SELECT * FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "해당 회사를 찾을 수 없습니다.",
|
||||||
|
errorCode: "COMPANY_NOT_FOUND",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("회사 정보 조회 성공", {
|
||||||
|
companyCode: company.company_code,
|
||||||
|
companyName: company.company_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
message: "회사 정보 조회 성공",
|
||||||
|
data: {
|
||||||
|
companyCode: company.company_code,
|
||||||
|
companyName: company.company_name,
|
||||||
|
businessRegistrationNumber: company.business_registration_number,
|
||||||
|
representativeName: company.representative_name,
|
||||||
|
representativePhone: company.representative_phone,
|
||||||
|
email: company.email,
|
||||||
|
website: company.website,
|
||||||
|
address: company.address,
|
||||||
|
status: company.status,
|
||||||
|
writer: company.writer,
|
||||||
|
regdate: company.regdate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 정보 조회 중 오류가 발생했습니다.",
|
||||||
|
errorCode: "COMPANY_GET_ERROR",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/admin/companies/:companyCode
|
* PUT /api/admin/companies/:companyCode
|
||||||
* 회사 정보 수정 API
|
* 회사 정보 수정 API
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export class EntityJoinController {
|
||||||
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
|
||||||
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -111,6 +112,19 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 처리
|
||||||
|
let parsedDataFilter: any = undefined;
|
||||||
|
if (dataFilter) {
|
||||||
|
try {
|
||||||
|
parsedDataFilter =
|
||||||
|
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
|
||||||
|
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("데이터 필터 파싱 오류:", error);
|
||||||
|
parsedDataFilter = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -126,6 +140,7 @@ export class EntityJoinController {
|
||||||
enableEntityJoin === "true" || enableEntityJoin === true,
|
enableEntityJoin === "true" || enableEntityJoin === true,
|
||||||
additionalJoinColumns: parsedAdditionalJoinColumns,
|
additionalJoinColumns: parsedAdditionalJoinColumns,
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,23 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||||
// 화면 목록 조회
|
// 화면 목록 조회
|
||||||
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { companyCode } = req.user as any;
|
const userCompanyCode = (req.user as any).companyCode;
|
||||||
const { page = 1, size = 20, searchTerm } = req.query;
|
const { page = 1, size = 20, searchTerm, companyCode } = req.query;
|
||||||
|
|
||||||
|
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
|
||||||
|
// 아니면 현재 사용자의 companyCode 사용
|
||||||
|
const targetCompanyCode = (companyCode as string) || userCompanyCode;
|
||||||
|
|
||||||
|
// 최고 관리자가 아닌 경우 자신의 회사 코드만 사용 가능
|
||||||
|
if (userCompanyCode !== "*" && targetCompanyCode !== userCompanyCode) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 화면을 조회할 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await screenManagementService.getScreensByCompany(
|
const result = await screenManagementService.getScreensByCompany(
|
||||||
companyCode,
|
targetCompanyCode,
|
||||||
parseInt(page as string),
|
parseInt(page as string),
|
||||||
parseInt(size as string)
|
parseInt(size as string)
|
||||||
);
|
);
|
||||||
|
|
@ -325,7 +337,118 @@ export const bulkPermanentDeleteScreens = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 복사
|
// 연결된 모달 화면 감지 (화면 복사 전 확인)
|
||||||
|
export const detectLinkedScreens = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const linkedScreens = await screenManagementService.detectLinkedModalScreens(
|
||||||
|
parseInt(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: linkedScreens,
|
||||||
|
message: linkedScreens.length > 0
|
||||||
|
? `${linkedScreens.length}개의 연결된 모달 화면을 감지했습니다.`
|
||||||
|
: "연결된 모달 화면이 없습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연결된 화면 감지 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "연결된 화면 감지에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면명 중복 체크
|
||||||
|
export const checkDuplicateScreenName = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { companyCode, screenName } = req.body;
|
||||||
|
|
||||||
|
if (!companyCode || !screenName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "companyCode와 screenName은 필수입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDuplicate =
|
||||||
|
await screenManagementService.checkDuplicateScreenName(
|
||||||
|
companyCode,
|
||||||
|
screenName
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { isDuplicate },
|
||||||
|
message: isDuplicate
|
||||||
|
? "이미 존재하는 화면명입니다."
|
||||||
|
: "사용 가능한 화면명입니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면명 중복 체크 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "화면명 중복 체크에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 일괄 복사 (메인 + 모달 화면들)
|
||||||
|
export const copyScreenWithModals = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { mainScreen, modalScreens, targetCompanyCode } = req.body;
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
|
||||||
|
if (!mainScreen || !mainScreen.screenName || !mainScreen.screenCode) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메인 화면 정보(screenName, screenCode)가 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await screenManagementService.copyScreenWithModals({
|
||||||
|
sourceScreenId: parseInt(id),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
targetCompanyCode, // 최고 관리자가 다른 회사로 복사할 때 사용
|
||||||
|
mainScreen: {
|
||||||
|
screenName: mainScreen.screenName,
|
||||||
|
screenCode: mainScreen.screenCode,
|
||||||
|
description: mainScreen.description,
|
||||||
|
},
|
||||||
|
modalScreens: modalScreens || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: `화면 복사가 완료되었습니다. (메인 1개 + 모달 ${result.modalScreens.length}개)`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면 일괄 복사 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "화면 일괄 복사에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 복사 (단일 - 하위 호환용)
|
||||||
export const copyScreen = async (
|
export const copyScreen = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
res: Response
|
||||||
|
|
@ -495,6 +618,50 @@ export const generateScreenCode = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 여러 개의 화면 코드 일괄 생성
|
||||||
|
export const generateMultipleScreenCodes = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode, count } = req.body;
|
||||||
|
|
||||||
|
if (!companyCode || typeof companyCode !== "string") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 코드(companyCode)는 필수입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!count || typeof count !== "number" || count < 1 || count > 100) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "count는 1~100 사이의 숫자여야 합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenCodes =
|
||||||
|
await screenManagementService.generateMultipleScreenCodes(
|
||||||
|
companyCode,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { screenCodes },
|
||||||
|
message: `${count}개의 화면 코드가 생성되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면 코드 일괄 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "화면 코드 일괄 생성에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 화면-메뉴 할당
|
// 화면-메뉴 할당
|
||||||
export const assignScreenToMenu = async (
|
export const assignScreenToMenu = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -268,3 +268,236 @@ export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Resp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ================================================
|
||||||
|
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||||
|
// ================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 조회
|
||||||
|
*
|
||||||
|
* GET /api/categories/column-mapping/:tableName/:menuObjid
|
||||||
|
*
|
||||||
|
* 특정 테이블과 메뉴에 대한 논리적 컬럼명 → 물리적 컬럼명 매핑을 조회합니다.
|
||||||
|
*
|
||||||
|
* @returns { logical_column: physical_column } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
export const getColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { tableName, menuObjid } = req.params;
|
||||||
|
|
||||||
|
if (!tableName || !menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName과 menuObjid는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 조회", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapping = await tableCategoryValueService.getColumnMapping(
|
||||||
|
tableName,
|
||||||
|
Number(menuObjid),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: mapping,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 매핑 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 생성/수정
|
||||||
|
*
|
||||||
|
* POST /api/categories/column-mapping
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - tableName: 테이블명
|
||||||
|
* - logicalColumnName: 논리적 컬럼명 (예: status_stock)
|
||||||
|
* - physicalColumnName: 물리적 컬럼명 (예: status)
|
||||||
|
* - menuObjid: 메뉴 OBJID
|
||||||
|
* - description: 설명 (선택사항)
|
||||||
|
*/
|
||||||
|
export const createColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
description,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 생성", {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapping = await tableCategoryValueService.createColumnMapping(
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
Number(menuObjid),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
description
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: mapping,
|
||||||
|
message: "컬럼 매핑이 생성되었습니다",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "컬럼 매핑 생성 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 논리적 컬럼 목록 조회
|
||||||
|
*
|
||||||
|
* GET /api/categories/logical-columns/:tableName/:menuObjid
|
||||||
|
*
|
||||||
|
* 특정 테이블과 메뉴에 대한 논리적 컬럼 목록을 조회합니다.
|
||||||
|
* (카테고리 값 추가 시 컬럼 선택용)
|
||||||
|
*/
|
||||||
|
export const getLogicalColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { tableName, menuObjid } = req.params;
|
||||||
|
|
||||||
|
if (!tableName || !menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName과 menuObjid는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("논리적 컬럼 목록 조회", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = await tableCategoryValueService.getLogicalColumns(
|
||||||
|
tableName,
|
||||||
|
Number(menuObjid),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "논리적 컬럼 목록 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 삭제
|
||||||
|
*
|
||||||
|
* DELETE /api/categories/column-mapping/:mappingId
|
||||||
|
*/
|
||||||
|
export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { mappingId } = req.params;
|
||||||
|
|
||||||
|
if (!mappingId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "mappingId는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 삭제", {
|
||||||
|
mappingId,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tableCategoryValueService.deleteColumnMapping(
|
||||||
|
Number(mappingId),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "컬럼 매핑이 삭제되었습니다",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -742,6 +742,7 @@ export async function getTableData(
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder = "asc",
|
sortOrder = "asc",
|
||||||
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
|
||||||
|
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
||||||
|
|
@ -749,6 +750,7 @@ export async function getTableData(
|
||||||
logger.info(`검색 조건:`, search);
|
logger.info(`검색 조건:`, search);
|
||||||
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
logger.info(`정렬: ${sortBy} ${sortOrder}`);
|
||||||
logger.info(`자동 필터:`, autoFilter); // 🆕
|
logger.info(`자동 필터:`, autoFilter); // 🆕
|
||||||
|
logger.info(`데이터 필터:`, dataFilter); // 🆕
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
|
|
@ -796,6 +798,7 @@ export async function getTableData(
|
||||||
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -1657,37 +1660,108 @@ export async function getCategoryColumnsByMenu(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
|
// 3. category_column_mapping 테이블 존재 여부 확인
|
||||||
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode });
|
const tableExistsResult = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
const columnsQuery = `
|
SELECT FROM information_schema.tables
|
||||||
SELECT
|
WHERE table_name = 'category_column_mapping'
|
||||||
ttc.table_name AS "tableName",
|
) as table_exists
|
||||||
COALESCE(
|
`);
|
||||||
tl.table_label,
|
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
|
||||||
initcap(replace(ttc.table_name, '_', ' '))
|
|
||||||
) AS "tableLabel",
|
let columnsResult;
|
||||||
ttc.column_name AS "columnName",
|
|
||||||
COALESCE(
|
if (mappingTableExists) {
|
||||||
cl.column_label,
|
// 🆕 category_column_mapping을 사용한 필터링
|
||||||
initcap(replace(ttc.column_name, '_', ' '))
|
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||||
) AS "columnLabel",
|
|
||||||
ttc.input_type AS "inputType"
|
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
|
||||||
FROM table_type_columns ttc
|
const ancestorMenuQuery = `
|
||||||
LEFT JOIN column_labels cl
|
WITH RECURSIVE menu_hierarchy AS (
|
||||||
ON ttc.table_name = cl.table_name
|
-- 현재 메뉴
|
||||||
AND ttc.column_name = cl.column_name
|
SELECT objid, parent_obj_id, menu_type
|
||||||
LEFT JOIN table_labels tl
|
FROM menu_info
|
||||||
ON ttc.table_name = tl.table_name
|
WHERE objid = $1
|
||||||
WHERE ttc.table_name = ANY($1)
|
|
||||||
AND ttc.company_code = $2
|
UNION ALL
|
||||||
AND ttc.input_type = 'category'
|
|
||||||
ORDER BY ttc.table_name, ttc.column_name
|
-- 부모 메뉴 재귀 조회
|
||||||
`;
|
SELECT m.objid, m.parent_obj_id, m.menu_type
|
||||||
|
FROM menu_info m
|
||||||
logger.info("🔍 카테고리 컬럼 쿼리 실행 중...");
|
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
|
||||||
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
|
WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외
|
||||||
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length });
|
)
|
||||||
|
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("✅ 카테고리 컬럼 조회 완료", {
|
logger.info("✅ 카테고리 컬럼 조회 완료", {
|
||||||
columnCount: columnsResult.rows.length
|
columnCount: columnsResult.rows.length
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
|
getCompanyByCode, // 회사 단건 조회
|
||||||
createCompany, // 회사 등록
|
createCompany, // 회사 등록
|
||||||
updateCompany, // 회사 수정
|
updateCompany, // 회사 수정
|
||||||
deleteCompany, // 회사 삭제
|
deleteCompany, // 회사 삭제
|
||||||
|
|
@ -60,6 +61,7 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||||
// 회사 관리 API
|
// 회사 관리 API
|
||||||
router.get("/companies", getCompanyList);
|
router.get("/companies", getCompanyList);
|
||||||
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
|
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
|
||||||
|
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회
|
||||||
router.post("/companies", createCompany); // 회사 등록
|
router.post("/companies", createCompany); // 회사 등록
|
||||||
router.put("/companies/:companyCode", updateCompany); // 회사 수정
|
router.put("/companies/:companyCode", updateCompany); // 회사 수정
|
||||||
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ router.get(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } =
|
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
|
||||||
req.query;
|
req.query;
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
|
|
@ -27,6 +27,16 @@ router.get(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataFilter 파싱 (JSON 문자열로 전달됨)
|
||||||
|
let parsedDataFilter = undefined;
|
||||||
|
if (dataFilter && typeof dataFilter === "string") {
|
||||||
|
try {
|
||||||
|
parsedDataFilter = JSON.parse(dataFilter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("dataFilter 파싱 오류:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 검증
|
// SQL 인젝션 방지를 위한 검증
|
||||||
const tables = [leftTable as string, rightTable as string];
|
const tables = [leftTable as string, rightTable as string];
|
||||||
const columns = [leftColumn as string, rightColumn as string];
|
const columns = [leftColumn as string, rightColumn as string];
|
||||||
|
|
@ -61,16 +71,18 @@ router.get(
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
userCompany,
|
userCompany,
|
||||||
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
|
||||||
});
|
});
|
||||||
|
|
||||||
// 조인 데이터 조회 (회사 코드 전달)
|
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
|
||||||
const result = await dataService.getJoinedData(
|
const result = await dataService.getJoinedData(
|
||||||
leftTable as string,
|
leftTable as string,
|
||||||
rightTable as string,
|
rightTable as string,
|
||||||
leftColumn as string,
|
leftColumn as string,
|
||||||
rightColumn as string,
|
rightColumn as string,
|
||||||
leftValue as string,
|
leftValue as string,
|
||||||
userCompany
|
userCompany,
|
||||||
|
parsedDataFilter // 🆕 데이터 필터 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,17 @@ import {
|
||||||
permanentDeleteScreen,
|
permanentDeleteScreen,
|
||||||
getDeletedScreens,
|
getDeletedScreens,
|
||||||
bulkPermanentDeleteScreens,
|
bulkPermanentDeleteScreens,
|
||||||
|
detectLinkedScreens,
|
||||||
|
checkDuplicateScreenName,
|
||||||
copyScreen,
|
copyScreen,
|
||||||
|
copyScreenWithModals,
|
||||||
getTables,
|
getTables,
|
||||||
getTableInfo,
|
getTableInfo,
|
||||||
getTableColumns,
|
getTableColumns,
|
||||||
saveLayout,
|
saveLayout,
|
||||||
getLayout,
|
getLayout,
|
||||||
generateScreenCode,
|
generateScreenCode,
|
||||||
|
generateMultipleScreenCodes,
|
||||||
assignScreenToMenu,
|
assignScreenToMenu,
|
||||||
getScreensByMenu,
|
getScreensByMenu,
|
||||||
unassignScreenFromMenu,
|
unassignScreenFromMenu,
|
||||||
|
|
@ -40,7 +44,10 @@ router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
router.post("/screens/:id/copy", copyScreen);
|
router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
|
||||||
|
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
|
||||||
|
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
|
||||||
|
router.post("/screens/:id/copy-with-modals", copyScreenWithModals); // 메인 + 모달 일괄 복사
|
||||||
|
|
||||||
// 휴지통 관리
|
// 휴지통 관리
|
||||||
router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록
|
router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록
|
||||||
|
|
@ -51,6 +58,9 @@ router.delete("/screens/trash/bulk", bulkPermanentDeleteScreens); // 일괄 영
|
||||||
// 화면 코드 자동 생성
|
// 화면 코드 자동 생성
|
||||||
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
||||||
|
|
||||||
|
// 여러 개의 화면 코드 일괄 생성
|
||||||
|
router.post("/generate-screen-codes", generateMultipleScreenCodes);
|
||||||
|
|
||||||
// 테이블 관리
|
// 테이블 관리
|
||||||
router.get("/tables", getTables);
|
router.get("/tables", getTables);
|
||||||
router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화)
|
router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import {
|
||||||
deleteCategoryValue,
|
deleteCategoryValue,
|
||||||
bulkDeleteCategoryValues,
|
bulkDeleteCategoryValues,
|
||||||
reorderCategoryValues,
|
reorderCategoryValues,
|
||||||
|
getColumnMapping,
|
||||||
|
createColumnMapping,
|
||||||
|
getLogicalColumns,
|
||||||
|
deleteColumnMapping,
|
||||||
|
getSecondLevelMenus,
|
||||||
} from "../controllers/tableCategoryValueController";
|
} from "../controllers/tableCategoryValueController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -36,5 +41,24 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||||
// 카테고리 값 순서 변경
|
// 카테고리 값 순서 변경
|
||||||
router.post("/values/reorder", reorderCategoryValues);
|
router.post("/values/reorder", reorderCategoryValues);
|
||||||
|
|
||||||
|
// ================================================
|
||||||
|
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||||
|
// ================================================
|
||||||
|
|
||||||
|
// 2레벨 메뉴 목록 조회 (메뉴 선택용)
|
||||||
|
router.get("/second-level-menus", getSecondLevelMenus);
|
||||||
|
|
||||||
|
// 컬럼 매핑 조회
|
||||||
|
router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping);
|
||||||
|
|
||||||
|
// 논리적 컬럼 목록 조회
|
||||||
|
router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
|
||||||
|
|
||||||
|
// 컬럼 매핑 생성/수정
|
||||||
|
router.post("/column-mapping", createColumnMapping);
|
||||||
|
|
||||||
|
// 컬럼 매핑 삭제
|
||||||
|
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||||
*/
|
*/
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -434,7 +435,8 @@ class DataService {
|
||||||
leftColumn: string,
|
leftColumn: string,
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: string | number,
|
leftValue?: string | number,
|
||||||
userCompany?: string
|
userCompany?: string,
|
||||||
|
dataFilter?: any // 🆕 데이터 필터
|
||||||
): Promise<ServiceResponse<any[]>> {
|
): Promise<ServiceResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
// 왼쪽 테이블 접근 검증
|
// 왼쪽 테이블 접근 검증
|
||||||
|
|
@ -478,6 +480,17 @@ class DataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
|
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||||
|
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
||||||
|
if (filterResult.whereClause) {
|
||||||
|
whereConditions.push(filterResult.whereClause);
|
||||||
|
values.push(...filterResult.params);
|
||||||
|
paramIndex += filterResult.params.length;
|
||||||
|
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WHERE 절 추가
|
// WHERE 절 추가
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
|
queryText += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
|
||||||
|
|
@ -223,12 +223,14 @@ export class EntityJoinService {
|
||||||
const aliasMap = new Map<string, string>();
|
const aliasMap = new Map<string, string>();
|
||||||
const usedAliasesForColumns = new Set<string>();
|
const usedAliasesForColumns = new Set<string>();
|
||||||
|
|
||||||
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
|
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
|
||||||
|
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
|
||||||
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
||||||
if (
|
if (
|
||||||
!acc.some(
|
!acc.some(
|
||||||
(existingConfig) =>
|
(existingConfig) =>
|
||||||
existingConfig.referenceTable === config.referenceTable
|
existingConfig.referenceTable === config.referenceTable &&
|
||||||
|
existingConfig.sourceColumn === config.sourceColumn
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
acc.push(config);
|
acc.push(config);
|
||||||
|
|
@ -237,7 +239,7 @@ export class EntityJoinService {
|
||||||
}, [] as EntityJoinConfig[]);
|
}, [] as EntityJoinConfig[]);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블`
|
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합`
|
||||||
);
|
);
|
||||||
|
|
||||||
uniqueReferenceTableConfigs.forEach((config) => {
|
uniqueReferenceTableConfigs.forEach((config) => {
|
||||||
|
|
@ -250,13 +252,16 @@ export class EntityJoinService {
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
usedAliasesForColumns.add(alias);
|
usedAliasesForColumns.add(alias);
|
||||||
aliasMap.set(config.referenceTable, alias);
|
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||||
logger.info(`🔧 별칭 생성: ${config.referenceTable} → ${alias}`);
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
|
aliasMap.set(aliasKey, alias);
|
||||||
|
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const joinColumns = joinConfigs
|
const joinColumns = joinConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const alias = aliasMap.get(config.referenceTable);
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
|
const alias = aliasMap.get(aliasKey);
|
||||||
const displayColumns = config.displayColumns || [
|
const displayColumns = config.displayColumns || [
|
||||||
config.displayColumn,
|
config.displayColumn,
|
||||||
];
|
];
|
||||||
|
|
@ -346,14 +351,16 @@ export class EntityJoinService {
|
||||||
// FROM 절 (메인 테이블)
|
// FROM 절 (메인 테이블)
|
||||||
const fromClause = `FROM ${tableName} main`;
|
const fromClause = `FROM ${tableName} main`;
|
||||||
|
|
||||||
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거)
|
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.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') {
|
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}`;
|
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 aliasMap = new Map<string, string>();
|
||||||
const usedAliases = new Set<string>();
|
const usedAliases = new Set<string>();
|
||||||
|
|
||||||
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
|
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
|
||||||
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
||||||
if (
|
if (
|
||||||
!acc.some(
|
!acc.some(
|
||||||
(existingConfig) =>
|
(existingConfig) =>
|
||||||
existingConfig.referenceTable === config.referenceTable
|
existingConfig.referenceTable === config.referenceTable &&
|
||||||
|
existingConfig.sourceColumn === config.sourceColumn
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
acc.push(config);
|
acc.push(config);
|
||||||
|
|
@ -561,13 +569,22 @@ export class EntityJoinService {
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
usedAliases.add(alias);
|
usedAliases.add(alias);
|
||||||
aliasMap.set(config.referenceTable, alias);
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
|
aliasMap.set(aliasKey, alias);
|
||||||
});
|
});
|
||||||
|
|
||||||
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
|
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.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}`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
|
||||||
|
|
@ -539,29 +539,43 @@ export class RoleService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
* 전체 메뉴 목록 조회 (권한 설정용)
|
||||||
*/
|
*
|
||||||
/**
|
* @param companyCode - 회사 코드
|
||||||
* 전체 메뉴 목록 조회 (권한 설정용)
|
* - undefined: 최고 관리자 - 모든 회사의 모든 메뉴 조회
|
||||||
|
* - "*": 최고 관리자의 공통 메뉴만 조회 (최고 관리자 전용)
|
||||||
|
* - "COMPANY_X": 해당 회사 메뉴만 조회 (공통 메뉴 제외)
|
||||||
|
*
|
||||||
|
* 중요:
|
||||||
|
* - 공통 메뉴(company_code = "*")는 최고 관리자 전용 메뉴입니다.
|
||||||
|
* - menu_type = 2 (화면)는 제외하고 메뉴만 조회합니다.
|
||||||
*/
|
*/
|
||||||
static async getAllMenus(companyCode?: string): Promise<any[]> {
|
static async getAllMenus(companyCode?: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
|
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
|
||||||
|
|
||||||
let whereConditions: string[] = ["status = 'active'"];
|
let whereConditions: string[] = [
|
||||||
|
"status = 'active'",
|
||||||
|
"menu_type != 2" // 화면 제외, 메뉴만 조회
|
||||||
|
];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 회사 코드 필터 (선택적)
|
// 회사 코드에 따른 필터링
|
||||||
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
|
if (companyCode === undefined) {
|
||||||
// 회사 코드 필터 (선택적)
|
// 최고 관리자: 모든 메뉴 조회
|
||||||
if (companyCode) {
|
logger.info("📋 최고 관리자 모드: 모든 메뉴 조회");
|
||||||
// 특정 회사 메뉴만 조회 (공통 메뉴 제외)
|
} else if (companyCode === "*") {
|
||||||
|
// 공통 메뉴만 조회
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
|
params.push("*");
|
||||||
|
paramIndex++;
|
||||||
|
logger.info("📋 공통 메뉴만 조회");
|
||||||
|
} else {
|
||||||
|
// 특정 회사: 해당 회사 메뉴 + 공통 메뉴 조회
|
||||||
|
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||||
params.push(companyCode);
|
params.push(companyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode });
|
logger.info("📋 회사별 필터 적용 (해당 회사 + 공통 메뉴)", { companyCode });
|
||||||
} else {
|
|
||||||
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
@ -573,13 +587,19 @@ export class RoleService {
|
||||||
menu_name_eng AS "menuNameEng",
|
menu_name_eng AS "menuNameEng",
|
||||||
menu_code AS "menuCode",
|
menu_code AS "menuCode",
|
||||||
menu_url AS "menuUrl",
|
menu_url AS "menuUrl",
|
||||||
menu_type AS "menuType",
|
CAST(menu_type AS TEXT) AS "menuType",
|
||||||
parent_obj_id AS "parentObjid",
|
parent_obj_id AS "parentObjid",
|
||||||
seq AS "sortOrder",
|
seq AS "sortOrder",
|
||||||
company_code AS "companyCode"
|
company_code AS "companyCode"
|
||||||
FROM menu_info
|
FROM menu_info
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY seq, menu_name_kor
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN parent_obj_id = 0 OR parent_obj_id IS NULL THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
seq,
|
||||||
|
menu_name_kor
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info("🔍 SQL 쿼리 실행", {
|
logger.info("🔍 SQL 쿼리 실행", {
|
||||||
|
|
@ -592,8 +612,9 @@ export class RoleService {
|
||||||
|
|
||||||
logger.info("✅ 메뉴 목록 조회 성공", {
|
logger.info("✅ 메뉴 목록 조회 성공", {
|
||||||
count: result.length,
|
count: result.length,
|
||||||
companyCode,
|
companyCode: companyCode || "전체",
|
||||||
menus: result.map((m) => ({
|
companyCodes: [...new Set(result.map((m) => m.companyCode))],
|
||||||
|
menus: result.slice(0, 5).map((m) => ({
|
||||||
objid: m.objid,
|
objid: m.objid,
|
||||||
name: m.menuName,
|
name: m.menuName,
|
||||||
code: m.menuCode,
|
code: m.menuCode,
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ interface CopyScreenRequest {
|
||||||
screenName: string;
|
screenName: string;
|
||||||
screenCode: string;
|
screenCode: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
companyCode: string;
|
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
|
||||||
createdBy: string;
|
userId: string;
|
||||||
|
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 백엔드에서 사용할 테이블 정보 타입
|
// 백엔드에서 사용할 테이블 정보 타입
|
||||||
|
|
@ -1513,6 +1514,7 @@ export class ScreenManagementService {
|
||||||
throw new Error("이미 할당된 화면입니다.");
|
throw new Error("이미 할당된 화면입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// screen_menu_assignments에 할당 추가
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO screen_menu_assignments (
|
`INSERT INTO screen_menu_assignments (
|
||||||
screen_id, menu_objid, company_code, display_order, created_by
|
screen_id, menu_objid, company_code, display_order, created_by
|
||||||
|
|
@ -1525,6 +1527,40 @@ export class ScreenManagementService {
|
||||||
assignmentData.createdBy || null,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1588,11 +1624,26 @@ export class ScreenManagementService {
|
||||||
menuObjid: number,
|
menuObjid: number,
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// screen_menu_assignments에서 할당 삭제
|
||||||
await query(
|
await query(
|
||||||
`DELETE FROM screen_menu_assignments
|
`DELETE FROM screen_menu_assignments
|
||||||
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
|
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
|
||||||
[screenId, menuObjid, companyCode]
|
[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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -1841,37 +1892,191 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
|
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
|
||||||
|
* 동시성 문제 방지: Advisory Lock 사용
|
||||||
*/
|
*/
|
||||||
async generateScreenCode(companyCode: string): Promise<string> {
|
async generateScreenCode(companyCode: string): Promise<string> {
|
||||||
// 해당 회사의 기존 화면 코드들 조회 (Raw Query)
|
return await transaction(async (client) => {
|
||||||
const existingScreens = await query<{ screen_code: string }>(
|
// 회사 코드를 숫자로 변환하여 advisory lock ID로 사용
|
||||||
`SELECT screen_code FROM screen_definitions
|
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||||
WHERE company_code = $1 AND screen_code LIKE $2
|
|
||||||
ORDER BY screen_code DESC`,
|
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
|
||||||
[companyCode, `${companyCode}%`]
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
);
|
|
||||||
|
|
||||||
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
// 해당 회사의 기존 화면 코드들 조회
|
||||||
let maxNumber = 0;
|
const existingScreens = await client.query<{ screen_code: string }>(
|
||||||
const pattern = new RegExp(
|
`SELECT screen_code FROM screen_definitions
|
||||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
WHERE company_code = $1 AND screen_code LIKE $2
|
||||||
);
|
ORDER BY screen_code DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[companyCode, `${companyCode}%`]
|
||||||
|
);
|
||||||
|
|
||||||
for (const screen of existingScreens) {
|
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
|
||||||
const match = screen.screen_code.match(pattern);
|
let maxNumber = 0;
|
||||||
if (match) {
|
const pattern = new RegExp(
|
||||||
const number = parseInt(match[1], 10);
|
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||||
if (number > maxNumber) {
|
);
|
||||||
maxNumber = number;
|
|
||||||
|
for (const screen of existingScreens.rows) {
|
||||||
|
const match = screen.screen_code.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const number = parseInt(match[1], 10);
|
||||||
|
if (number > maxNumber) {
|
||||||
|
maxNumber = number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
||||||
|
const nextNumber = maxNumber + 1;
|
||||||
|
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||||
|
|
||||||
|
const newCode = `${companyCode}_${paddedNumber}`;
|
||||||
|
console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`);
|
||||||
|
|
||||||
|
return newCode;
|
||||||
|
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 개의 화면 코드를 한 번에 생성 (중복 방지)
|
||||||
|
* 한 트랜잭션 내에서 순차적으로 생성하여 중복 방지
|
||||||
|
*/
|
||||||
|
async generateMultipleScreenCodes(
|
||||||
|
companyCode: string,
|
||||||
|
count: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// Advisory lock 획득
|
||||||
|
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||||
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
|
// 현재 최대 번호 조회
|
||||||
|
const existingScreens = await client.query<{ screen_code: string }>(
|
||||||
|
`SELECT screen_code FROM screen_definitions
|
||||||
|
WHERE company_code = $1 AND screen_code LIKE $2
|
||||||
|
ORDER BY screen_code DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[companyCode, `${companyCode}%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
let maxNumber = 0;
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const screen of existingScreens.rows) {
|
||||||
|
const match = screen.screen_code.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const number = parseInt(match[1], 10);
|
||||||
|
if (number > maxNumber) {
|
||||||
|
maxNumber = number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// count개의 코드를 순차적으로 생성
|
||||||
|
const codes: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const nextNumber = maxNumber + i + 1;
|
||||||
|
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
||||||
|
codes.push(`${companyCode}_${paddedNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(', ')}]`);
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면명 중복 체크
|
||||||
|
* 같은 회사 내에서 동일한 화면명이 있는지 확인
|
||||||
|
*/
|
||||||
|
async checkDuplicateScreenName(
|
||||||
|
companyCode: string,
|
||||||
|
screenName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await query<any>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND screen_name = $2
|
||||||
|
AND deleted_date IS NULL`,
|
||||||
|
[companyCode, screenName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const count = parseInt(result[0]?.count || "0", 10);
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면에 연결된 모달 화면들을 자동 감지
|
||||||
|
* 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출
|
||||||
|
*/
|
||||||
|
async detectLinkedModalScreens(
|
||||||
|
screenId: number
|
||||||
|
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
|
||||||
|
// 화면의 모든 레이아웃 조회
|
||||||
|
const layouts = await query<any>(
|
||||||
|
`SELECT layout_id, properties
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = 'component'
|
||||||
|
AND properties IS NOT NULL`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkedScreenIds = new Set<number>();
|
||||||
|
|
||||||
|
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
|
||||||
|
for (const layout of layouts) {
|
||||||
|
try {
|
||||||
|
const properties = layout.properties;
|
||||||
|
|
||||||
|
// 버튼 컴포넌트인지 확인
|
||||||
|
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||||
|
const action = properties?.componentConfig?.action;
|
||||||
|
|
||||||
|
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
|
||||||
|
// edit 액션도 수정 폼 모달을 열기 때문에 포함
|
||||||
|
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
|
||||||
|
const targetScreenId = parseInt(action.targetScreenId);
|
||||||
|
if (!isNaN(targetScreenId)) {
|
||||||
|
linkedScreenIds.add(targetScreenId);
|
||||||
|
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// JSON 파싱 오류 등은 무시하고 계속 진행
|
||||||
|
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
|
// 감지된 화면 ID들의 정보 조회
|
||||||
const nextNumber = maxNumber + 1;
|
if (linkedScreenIds.size === 0) {
|
||||||
const paddedNumber = nextNumber.toString().padStart(3, "0");
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return `${companyCode}_${paddedNumber}`;
|
const screenIds = Array.from(linkedScreenIds);
|
||||||
|
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
const linkedScreens = await query<any>(
|
||||||
|
`SELECT screen_id, screen_name, screen_code
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE screen_id IN (${placeholders})
|
||||||
|
AND deleted_date IS NULL
|
||||||
|
ORDER BY screen_name`,
|
||||||
|
screenIds
|
||||||
|
);
|
||||||
|
|
||||||
|
return linkedScreens.map((s) => ({
|
||||||
|
screenId: s.screen_id,
|
||||||
|
screenName: s.screen_name,
|
||||||
|
screenCode: s.screen_code,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1884,11 +2089,31 @@ export class ScreenManagementService {
|
||||||
// 트랜잭션으로 처리
|
// 트랜잭션으로 처리
|
||||||
return await transaction(async (client) => {
|
return await transaction(async (client) => {
|
||||||
// 1. 원본 화면 정보 조회
|
// 1. 원본 화면 정보 조회
|
||||||
|
// 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음
|
||||||
|
let sourceScreenQuery: string;
|
||||||
|
let sourceScreenParams: any[];
|
||||||
|
|
||||||
|
if (copyData.companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 회사의 화면 조회 가능
|
||||||
|
sourceScreenQuery = `
|
||||||
|
SELECT * FROM screen_definitions
|
||||||
|
WHERE screen_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
sourceScreenParams = [sourceScreenId];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 회사 화면만 조회 가능
|
||||||
|
sourceScreenQuery = `
|
||||||
|
SELECT * FROM screen_definitions
|
||||||
|
WHERE screen_id = $1 AND company_code = $2
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
sourceScreenParams = [sourceScreenId, copyData.companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
const sourceScreens = await client.query<any>(
|
const sourceScreens = await client.query<any>(
|
||||||
`SELECT * FROM screen_definitions
|
sourceScreenQuery,
|
||||||
WHERE screen_id = $1 AND company_code = $2
|
sourceScreenParams
|
||||||
LIMIT 1`,
|
|
||||||
[sourceScreenId, copyData.companyCode]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourceScreens.rows.length === 0) {
|
if (sourceScreens.rows.length === 0) {
|
||||||
|
|
@ -1897,19 +2122,24 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
const sourceScreen = sourceScreens.rows[0];
|
const sourceScreen = sourceScreens.rows[0];
|
||||||
|
|
||||||
// 2. 화면 코드 중복 체크
|
// 2. 대상 회사 코드 결정
|
||||||
|
// copyData.targetCompanyCode가 있으면 사용 (회사 간 복사)
|
||||||
|
// 없으면 원본과 같은 회사에 복사
|
||||||
|
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||||
|
|
||||||
|
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
||||||
const existingScreens = await client.query<any>(
|
const existingScreens = await client.query<any>(
|
||||||
`SELECT screen_id FROM screen_definitions
|
`SELECT screen_id FROM screen_definitions
|
||||||
WHERE screen_code = $1 AND company_code = $2
|
WHERE screen_code = $1 AND company_code = $2
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[copyData.screenCode, copyData.companyCode]
|
[copyData.screenCode, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingScreens.rows.length > 0) {
|
if (existingScreens.rows.length > 0) {
|
||||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 새 화면 생성
|
// 4. 새 화면 생성 (대상 회사에 생성)
|
||||||
const newScreenResult = await client.query<any>(
|
const newScreenResult = await client.query<any>(
|
||||||
`INSERT INTO screen_definitions (
|
`INSERT INTO screen_definitions (
|
||||||
screen_code, screen_name, description, company_code, table_name,
|
screen_code, screen_name, description, company_code, table_name,
|
||||||
|
|
@ -1920,12 +2150,12 @@ export class ScreenManagementService {
|
||||||
copyData.screenCode,
|
copyData.screenCode,
|
||||||
copyData.screenName,
|
copyData.screenName,
|
||||||
copyData.description || sourceScreen.description,
|
copyData.description || sourceScreen.description,
|
||||||
copyData.companyCode,
|
targetCompanyCode, // 대상 회사 코드 사용
|
||||||
sourceScreen.table_name,
|
sourceScreen.table_name,
|
||||||
sourceScreen.is_active,
|
sourceScreen.is_active,
|
||||||
copyData.createdBy,
|
copyData.userId,
|
||||||
new Date(),
|
new Date(),
|
||||||
copyData.createdBy,
|
copyData.userId,
|
||||||
new Date(),
|
new Date(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -2005,6 +2235,165 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 화면 + 연결된 모달 화면들 일괄 복사
|
||||||
|
*/
|
||||||
|
async copyScreenWithModals(data: {
|
||||||
|
sourceScreenId: number;
|
||||||
|
companyCode: string;
|
||||||
|
userId: string;
|
||||||
|
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
|
||||||
|
mainScreen: {
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
modalScreens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<{
|
||||||
|
mainScreen: ScreenDefinition;
|
||||||
|
modalScreens: ScreenDefinition[];
|
||||||
|
}> {
|
||||||
|
const targetCompany = data.targetCompanyCode || data.companyCode;
|
||||||
|
console.log(`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`);
|
||||||
|
|
||||||
|
// 1. 메인 화면 복사
|
||||||
|
const mainScreen = await this.copyScreen(data.sourceScreenId, {
|
||||||
|
screenName: data.mainScreen.screenName,
|
||||||
|
screenCode: data.mainScreen.screenCode,
|
||||||
|
description: data.mainScreen.description || "",
|
||||||
|
companyCode: data.companyCode,
|
||||||
|
userId: data.userId,
|
||||||
|
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`);
|
||||||
|
|
||||||
|
// 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑)
|
||||||
|
const modalScreens: ScreenDefinition[] = [];
|
||||||
|
const screenIdMapping: Map<number, number> = new Map(); // 원본 ID → 새 ID
|
||||||
|
|
||||||
|
for (const modalData of data.modalScreens) {
|
||||||
|
const copiedModal = await this.copyScreen(modalData.sourceScreenId, {
|
||||||
|
screenName: modalData.screenName,
|
||||||
|
screenCode: modalData.screenCode,
|
||||||
|
description: "",
|
||||||
|
companyCode: data.companyCode,
|
||||||
|
userId: data.userId,
|
||||||
|
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
|
||||||
|
});
|
||||||
|
|
||||||
|
modalScreens.push(copiedModal);
|
||||||
|
screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ 모달 화면 복사 완료: ${modalData.sourceScreenId} → ${copiedModal.screenId} (${copiedModal.screenCode})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트
|
||||||
|
// 모든 복사가 완료되고 커밋된 후에 실행
|
||||||
|
console.log(`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`,
|
||||||
|
Array.from(screenIdMapping.entries())
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCount = await this.updateButtonTargetScreenIds(
|
||||||
|
mainScreen.screenId,
|
||||||
|
screenIdMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainScreen,
|
||||||
|
modalScreens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 레이아웃에서 버튼의 targetScreenId를 새 screenId로 업데이트
|
||||||
|
* (독립적인 트랜잭션으로 실행)
|
||||||
|
*/
|
||||||
|
private async updateButtonTargetScreenIds(
|
||||||
|
screenId: number,
|
||||||
|
screenIdMapping: Map<number, number>
|
||||||
|
): Promise<number> {
|
||||||
|
console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`);
|
||||||
|
|
||||||
|
// 화면의 모든 레이아웃 조회
|
||||||
|
const layouts = await query<any>(
|
||||||
|
`SELECT layout_id, properties
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = 'component'
|
||||||
|
AND properties IS NOT NULL`,
|
||||||
|
[screenId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`);
|
||||||
|
|
||||||
|
let updateCount = 0;
|
||||||
|
|
||||||
|
for (const layout of layouts) {
|
||||||
|
try {
|
||||||
|
const properties = layout.properties;
|
||||||
|
|
||||||
|
// 버튼 컴포넌트인지 확인
|
||||||
|
if (
|
||||||
|
properties?.componentType === "button" ||
|
||||||
|
properties?.componentType?.startsWith("button-")
|
||||||
|
) {
|
||||||
|
const action = properties?.componentConfig?.action;
|
||||||
|
|
||||||
|
// targetScreenId가 있는 액션 (popup, modal, edit)
|
||||||
|
if (
|
||||||
|
(action?.type === "popup" ||
|
||||||
|
action?.type === "modal" ||
|
||||||
|
action?.type === "edit") &&
|
||||||
|
action?.targetScreenId
|
||||||
|
) {
|
||||||
|
const oldScreenId = parseInt(action.targetScreenId);
|
||||||
|
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||||
|
|
||||||
|
// 매핑에 있으면 업데이트
|
||||||
|
if (screenIdMapping.has(oldScreenId)) {
|
||||||
|
const newScreenId = screenIdMapping.get(oldScreenId)!;
|
||||||
|
console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`);
|
||||||
|
|
||||||
|
// properties 업데이트
|
||||||
|
properties.componentConfig.action.targetScreenId =
|
||||||
|
newScreenId.toString();
|
||||||
|
|
||||||
|
// 데이터베이스 업데이트
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_layouts
|
||||||
|
SET properties = $1
|
||||||
|
WHERE layout_id = $2`,
|
||||||
|
[JSON.stringify(properties), layout.layout_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCount++;
|
||||||
|
console.log(
|
||||||
|
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
|
||||||
|
// 개별 레이아웃 오류는 무시하고 계속 진행
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
|
||||||
|
return updateCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 인스턴스 export
|
// 서비스 인스턴스 export
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,429 @@ class TableCategoryValueService {
|
||||||
children: this.buildHierarchy(values, v.valueId!),
|
children: this.buildHierarchy(values, v.valueId!),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================
|
||||||
|
// 컬럼 매핑 관련 메서드 (논리명 ↔ 물리명)
|
||||||
|
// ================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @returns { logical_column: physical_column } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
async getColumnMapping(
|
||||||
|
tableName: string,
|
||||||
|
menuObjid: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode });
|
||||||
|
|
||||||
|
// 멀티테넌시 적용
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 매핑 조회 가능
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
logical_column_name AS "logicalColumnName",
|
||||||
|
physical_column_name AS "physicalColumnName"
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
`;
|
||||||
|
params = [tableName, menuObjid];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 매핑만 조회
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
logical_column_name AS "logicalColumnName",
|
||||||
|
physical_column_name AS "physicalColumnName"
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
AND company_code = $3
|
||||||
|
`;
|
||||||
|
params = [tableName, menuObjid, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
// { logical_column: physical_column } 형태로 변환
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
result.rows.forEach((row: any) => {
|
||||||
|
mapping[row.logicalColumnName] = row.physicalColumnName;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`컬럼 매핑 ${Object.keys(mapping).length}개 조회 완료`, {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 생성/수정
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param logicalColumnName - 논리적 컬럼명
|
||||||
|
* @param physicalColumnName - 물리적 컬럼명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @param userId - 사용자 ID
|
||||||
|
* @param description - 설명 (선택사항)
|
||||||
|
*/
|
||||||
|
async createColumnMapping(
|
||||||
|
tableName: string,
|
||||||
|
logicalColumnName: string,
|
||||||
|
physicalColumnName: string,
|
||||||
|
menuObjid: number,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
description?: string
|
||||||
|
): Promise<any> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("컬럼 매핑 생성", {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 물리적 컬럼이 실제로 존재하는지 확인
|
||||||
|
const columnCheckQuery = `
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columnCheck = await pool.query(columnCheckQuery, [
|
||||||
|
tableName,
|
||||||
|
physicalColumnName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (columnCheck.rowCount === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 매핑 저장 (UPSERT)
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO category_column_mapping (
|
||||||
|
table_name,
|
||||||
|
logical_column_name,
|
||||||
|
physical_column_name,
|
||||||
|
menu_objid,
|
||||||
|
company_code,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
physical_column_name = EXCLUDED.physical_column_name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = EXCLUDED.updated_by
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(insertQuery, [
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
description || null,
|
||||||
|
userId,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 생성 완료", {
|
||||||
|
mappingId: result.rows[0].mapping_id,
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 논리적 컬럼 목록 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @returns 논리적 컬럼 목록
|
||||||
|
*/
|
||||||
|
async getLogicalColumns(
|
||||||
|
tableName: string,
|
||||||
|
menuObjid: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<any[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("논리적 컬럼 목록 조회", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 멀티테넌시 적용
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 논리적 컬럼 조회
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
mapping_id AS "mappingId",
|
||||||
|
logical_column_name AS "logicalColumnName",
|
||||||
|
physical_column_name AS "physicalColumnName",
|
||||||
|
description
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
ORDER BY logical_column_name
|
||||||
|
`;
|
||||||
|
params = [tableName, menuObjid];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 논리적 컬럼만 조회
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
mapping_id AS "mappingId",
|
||||||
|
logical_column_name AS "logicalColumnName",
|
||||||
|
physical_column_name AS "physicalColumnName",
|
||||||
|
description
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
AND company_code = $3
|
||||||
|
ORDER BY logical_column_name
|
||||||
|
`;
|
||||||
|
params = [tableName, menuObjid, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info(`논리적 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 삭제
|
||||||
|
*
|
||||||
|
* @param mappingId - 매핑 ID
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
*/
|
||||||
|
async deleteColumnMapping(
|
||||||
|
mappingId: number,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("컬럼 매핑 삭제", { mappingId, companyCode });
|
||||||
|
|
||||||
|
// 멀티테넌시 적용
|
||||||
|
let deleteQuery: string;
|
||||||
|
let deleteParams: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 매핑 삭제 가능
|
||||||
|
deleteQuery = `
|
||||||
|
DELETE FROM category_column_mapping
|
||||||
|
WHERE mapping_id = $1
|
||||||
|
`;
|
||||||
|
deleteParams = [mappingId];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 매핑만 삭제 가능
|
||||||
|
deleteQuery = `
|
||||||
|
DELETE FROM category_column_mapping
|
||||||
|
WHERE mapping_id = $1
|
||||||
|
AND company_code = $2
|
||||||
|
`;
|
||||||
|
deleteParams = [mappingId, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(deleteQuery, deleteParams);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 삭제 완료", { mappingId, companyCode });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 논리적 컬럼명을 물리적 컬럼명으로 변환
|
||||||
|
*
|
||||||
|
* 데이터 저장 시 사용
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @param data - 논리적 컬럼명으로 된 데이터
|
||||||
|
* @returns 물리적 컬럼명으로 변환된 데이터
|
||||||
|
*/
|
||||||
|
async convertToPhysicalColumns(
|
||||||
|
tableName: string,
|
||||||
|
menuObjid: number,
|
||||||
|
companyCode: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
|
try {
|
||||||
|
// 컬럼 매핑 조회
|
||||||
|
const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode);
|
||||||
|
|
||||||
|
// 논리적 컬럼명 → 물리적 컬럼명 변환
|
||||||
|
const physicalData: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
|
||||||
|
physicalData[physicalColumn] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("컬럼명 변환 완료", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
logicalColumns: Object.keys(data),
|
||||||
|
physicalColumns: Object.keys(physicalData),
|
||||||
|
});
|
||||||
|
|
||||||
|
return physicalData;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`컬럼명 변환 실패: ${error.message}`);
|
||||||
|
// 매핑이 없으면 원본 데이터 그대로 반환
|
||||||
|
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();
|
export default new TableCategoryValueService();
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,8 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캐시 키 생성 (companyCode 포함)
|
// 캐시 키 생성 (companyCode 포함)
|
||||||
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
const cacheKey =
|
||||||
|
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
||||||
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
||||||
|
|
||||||
// 캐시에서 먼저 확인
|
// 캐시에서 먼저 확인
|
||||||
|
|
@ -162,9 +163,9 @@ export class TableManagementService {
|
||||||
|
|
||||||
// 페이지네이션 적용한 컬럼 조회
|
// 페이지네이션 적용한 컬럼 조회
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
|
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
|
||||||
const rawColumns = companyCode
|
const rawColumns = companyCode
|
||||||
? await query<any>(
|
? await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
c.column_name as "columnName",
|
c.column_name as "columnName",
|
||||||
|
|
@ -249,21 +250,89 @@ export class TableManagementService {
|
||||||
[tableName, size, offset]
|
[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 직렬화 문제 해결
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
||||||
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
|
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
|
||||||
...column,
|
const baseColumn = {
|
||||||
maxLength: column.maxLength ? Number(column.maxLength) : null,
|
...column,
|
||||||
numericPrecision: column.numericPrecision
|
maxLength: column.maxLength ? Number(column.maxLength) : null,
|
||||||
? Number(column.numericPrecision)
|
numericPrecision: column.numericPrecision
|
||||||
: null,
|
? Number(column.numericPrecision)
|
||||||
numericScale: column.numericScale ? Number(column.numericScale) : null,
|
: null,
|
||||||
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
|
numericScale: column.numericScale
|
||||||
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
|
? Number(column.numericScale)
|
||||||
webType:
|
: null,
|
||||||
column.webType === "text"
|
displayOrder: column.displayOrder
|
||||||
? this.inferWebType(column.dataType)
|
? Number(column.displayOrder)
|
||||||
: column.webType,
|
: null,
|
||||||
}));
|
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
|
||||||
|
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
|
||||||
|
webType: 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);
|
const totalPages = Math.ceil(total / size);
|
||||||
|
|
||||||
|
|
@ -360,7 +429,9 @@ export class TableManagementService {
|
||||||
companyCode: string // 🔥 회사 코드 추가
|
companyCode: string // 🔥 회사 코드 추가
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`);
|
logger.info(
|
||||||
|
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
|
||||||
|
);
|
||||||
|
|
||||||
// 테이블이 table_labels에 없으면 자동 추가
|
// 테이블이 table_labels에 없으면 자동 추가
|
||||||
await this.insertTableIfNotExists(tableName);
|
await this.insertTableIfNotExists(tableName);
|
||||||
|
|
@ -406,17 +477,22 @@ export class TableManagementService {
|
||||||
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용
|
||||||
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
let parsedDetailSettings: Record<string, any> | undefined = undefined;
|
||||||
if (settings.detailSettings) {
|
if (settings.detailSettings) {
|
||||||
if (typeof settings.detailSettings === 'string') {
|
if (typeof settings.detailSettings === "string") {
|
||||||
try {
|
try {
|
||||||
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
parsedDetailSettings = JSON.parse(settings.detailSettings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`);
|
logger.warn(
|
||||||
|
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (typeof settings.detailSettings === 'object') {
|
} else if (typeof settings.detailSettings === "object") {
|
||||||
parsedDetailSettings = settings.detailSettings as Record<string, any>;
|
parsedDetailSettings = settings.detailSettings as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateColumnInputType(
|
await this.updateColumnInputType(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
|
|
@ -484,7 +560,7 @@ export class TableManagementService {
|
||||||
cache.deleteByPattern(`table_columns:${tableName}:`);
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
||||||
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
||||||
|
|
||||||
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`);
|
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
|
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
|
||||||
|
|
@ -1076,7 +1152,7 @@ export class TableManagementService {
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
actualValue = value.value;
|
actualValue = value.value;
|
||||||
operator = value.operator || "contains";
|
operator = value.operator || "contains";
|
||||||
|
|
||||||
logger.info("🔍 필터 객체 처리:", {
|
logger.info("🔍 필터 객체 처리:", {
|
||||||
columnName,
|
columnName,
|
||||||
originalValue: value,
|
originalValue: value,
|
||||||
|
|
@ -1123,11 +1199,19 @@ export class TableManagementService {
|
||||||
switch (webType) {
|
switch (webType) {
|
||||||
case "date":
|
case "date":
|
||||||
case "datetime":
|
case "datetime":
|
||||||
return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
|
return this.buildDateRangeCondition(
|
||||||
|
columnName,
|
||||||
|
actualValue,
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex);
|
return this.buildNumberRangeCondition(
|
||||||
|
columnName,
|
||||||
|
actualValue,
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
|
|
||||||
case "code":
|
case "code":
|
||||||
return await this.buildCodeSearchCondition(
|
return await this.buildCodeSearchCondition(
|
||||||
|
|
@ -1163,7 +1247,7 @@ export class TableManagementService {
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
fallbackValue = value.value;
|
fallbackValue = value.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${fallbackValue}%`],
|
values: [`%${fallbackValue}%`],
|
||||||
|
|
@ -1526,6 +1610,7 @@ export class TableManagementService {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
dataFilter?: any; // 🆕 DataFilterConfig
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -1535,7 +1620,15 @@ export class TableManagementService {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
|
const {
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
search = {},
|
||||||
|
sortBy,
|
||||||
|
sortOrder = "asc",
|
||||||
|
companyCode,
|
||||||
|
dataFilter,
|
||||||
|
} = options;
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||||
|
|
@ -1554,7 +1647,9 @@ export class TableManagementService {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
searchValues.push(companyCode);
|
searchValues.push(companyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
|
logger.info(
|
||||||
|
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search && Object.keys(search).length > 0) {
|
if (search && Object.keys(search).length > 0) {
|
||||||
|
|
@ -1592,6 +1687,29 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용
|
||||||
|
if (
|
||||||
|
dataFilter &&
|
||||||
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
buildDataFilterWhereClause,
|
||||||
|
} = require("../utils/dataFilterUtil");
|
||||||
|
const { whereClause: filterWhere, params: filterParams } =
|
||||||
|
buildDataFilterWhereClause(dataFilter, paramIndex);
|
||||||
|
|
||||||
|
if (filterWhere) {
|
||||||
|
whereConditions.push(filterWhere);
|
||||||
|
searchValues.push(...filterParams);
|
||||||
|
paramIndex += filterParams.length;
|
||||||
|
|
||||||
|
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
|
||||||
|
logger.info(`🔍 필터 파라미터:`, filterParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause =
|
const whereClause =
|
||||||
whereConditions.length > 0
|
whereConditions.length > 0
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
|
@ -1623,7 +1741,9 @@ export class TableManagementService {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||||
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`);
|
logger.info(
|
||||||
|
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
|
||||||
|
);
|
||||||
|
|
||||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||||
|
|
||||||
|
|
@ -2095,6 +2215,7 @@ export class TableManagementService {
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
|
||||||
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2254,18 +2375,99 @@ export class TableManagementService {
|
||||||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
let whereClause = await this.buildWhereClause(
|
let whereClause = await this.buildWhereClause(tableName, options.search);
|
||||||
tableName,
|
|
||||||
options.search
|
|
||||||
);
|
|
||||||
|
|
||||||
// 멀티테넌시 필터 추가 (company_code)
|
// 멀티테넌시 필터 추가 (company_code)
|
||||||
if (options.companyCode) {
|
if (options.companyCode) {
|
||||||
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
|
||||||
whereClause = whereClause
|
whereClause = whereClause
|
||||||
? `${whereClause} AND ${companyFilter}`
|
? `${whereClause} AND ${companyFilter}`
|
||||||
: companyFilter;
|
: companyFilter;
|
||||||
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
|
logger.info(
|
||||||
|
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
|
||||||
|
if (
|
||||||
|
options.dataFilter &&
|
||||||
|
options.dataFilter.enabled &&
|
||||||
|
options.dataFilter.filters &&
|
||||||
|
options.dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const filterConditions: string[] = [];
|
||||||
|
|
||||||
|
for (const filter of options.dataFilter.filters) {
|
||||||
|
const { columnName, operator, value } = filter;
|
||||||
|
|
||||||
|
if (!columnName || value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeColumn = `main."${columnName}"`;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "equals":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "not_equals":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const values = value
|
||||||
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
|
.join(", ");
|
||||||
|
filterConditions.push(`${safeColumn} IN (${values})`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "not_in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const values = value
|
||||||
|
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
|
||||||
|
.join(", ");
|
||||||
|
filterConditions.push(`${safeColumn} NOT IN (${values})`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "contains":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "starts_with":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ends_with":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "is_null":
|
||||||
|
filterConditions.push(`${safeColumn} IS NULL`);
|
||||||
|
break;
|
||||||
|
case "is_not_null":
|
||||||
|
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterConditions.length > 0) {
|
||||||
|
const logicalOperator =
|
||||||
|
options.dataFilter.matchType === "any" ? " OR " : " AND ";
|
||||||
|
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
|
||||||
|
|
||||||
|
whereClause = whereClause
|
||||||
|
? `${whereClause} AND ${filterWhere}`
|
||||||
|
: filterWhere;
|
||||||
|
|
||||||
|
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORDER BY 절 구성
|
// ORDER BY 절 구성
|
||||||
|
|
@ -2347,12 +2549,19 @@ export class TableManagementService {
|
||||||
whereClause
|
whereClause
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ⚠️ SQL 쿼리 로깅 (디버깅용)
|
||||||
|
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
|
||||||
|
|
||||||
// 병렬 실행
|
// 병렬 실행
|
||||||
const [dataResult, countResult] = await Promise.all([
|
const [dataResult, countResult] = await Promise.all([
|
||||||
query(dataQuery),
|
query(dataQuery),
|
||||||
query(countQuery),
|
query(countQuery),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
|
||||||
|
);
|
||||||
|
|
||||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
const data = Array.isArray(dataResult) ? dataResult : [];
|
||||||
const total =
|
const total =
|
||||||
Array.isArray(countResult) && countResult.length > 0
|
Array.isArray(countResult) && countResult.length > 0
|
||||||
|
|
@ -2581,11 +2790,17 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
|
basicResult = await this.getTableData(tableName, {
|
||||||
|
...fallbackOptions,
|
||||||
|
companyCode: options.companyCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
||||||
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
|
basicResult = await this.getTableData(tableName, {
|
||||||
|
...options,
|
||||||
|
companyCode: options.companyCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 값들을 캐시에서 룩업하여 변환
|
// Entity 값들을 캐시에서 룩업하여 변환
|
||||||
|
|
@ -2859,13 +3074,20 @@ export class TableManagementService {
|
||||||
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
||||||
else {
|
else {
|
||||||
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
||||||
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
|
const companyCodeMatch = whereClause.match(
|
||||||
|
/main\.company_code\s*=\s*'([^']+)'/
|
||||||
|
);
|
||||||
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
||||||
|
|
||||||
return await this.executeCachedLookup(
|
return await this.executeCachedLookup(
|
||||||
tableName,
|
tableName,
|
||||||
cacheableJoins,
|
cacheableJoins,
|
||||||
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
|
{
|
||||||
|
page: Math.floor(offset / limit) + 1,
|
||||||
|
size: limit,
|
||||||
|
search: {},
|
||||||
|
companyCode,
|
||||||
|
},
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2887,7 +3109,7 @@ export class TableManagementService {
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === 'table_column_category_values') {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
dbJoins.push(config);
|
dbJoins.push(config);
|
||||||
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3152,19 +3374,88 @@ export class TableManagementService {
|
||||||
[tableName, companyCode]
|
[tableName, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
|
// category_column_mapping 테이블 존재 여부 확인
|
||||||
tableName: tableName,
|
const tableExistsResult = await query<any>(
|
||||||
columnName: col.columnName,
|
`SELECT EXISTS (
|
||||||
displayName: col.displayName,
|
SELECT FROM information_schema.tables
|
||||||
dataType: col.dataType || "varchar",
|
WHERE table_name = 'category_column_mapping'
|
||||||
inputType: col.inputType,
|
) as table_exists`
|
||||||
detailSettings: col.detailSettings,
|
);
|
||||||
description: "", // 필수 필드 추가
|
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
|
||||||
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
|
||||||
isPrimaryKey: false,
|
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
|
||||||
displayOrder: 0,
|
let categoryMappings: Map<string, number[]> = new Map();
|
||||||
isVisible: true,
|
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(
|
logger.info(
|
||||||
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
|
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
|
||||||
|
|
@ -3183,7 +3474,10 @@ export class TableManagementService {
|
||||||
* 레거시 지원: 컬럼 웹타입 정보 조회
|
* 레거시 지원: 컬럼 웹타입 정보 조회
|
||||||
* @deprecated getColumnInputTypes 사용 권장
|
* @deprecated getColumnInputTypes 사용 권장
|
||||||
*/
|
*/
|
||||||
async getColumnWebTypes(tableName: string, companyCode: string): Promise<ColumnTypeInfo[]> {
|
async getColumnWebTypes(
|
||||||
|
tableName: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<ColumnTypeInfo[]> {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* 데이터 필터 유틸리티
|
||||||
|
* 프론트엔드의 DataFilterConfig를 SQL WHERE 절로 변환
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ColumnFilter {
|
||||||
|
id: string;
|
||||||
|
columnName: string;
|
||||||
|
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
||||||
|
value: string | string[];
|
||||||
|
valueType: "static" | "category" | "code";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataFilterConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
filters: ColumnFilter[];
|
||||||
|
matchType: "all" | "any"; // AND / OR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataFilterConfig를 SQL WHERE 조건과 파라미터로 변환
|
||||||
|
* @param dataFilter 필터 설정
|
||||||
|
* @param tableAlias 테이블 별칭 (예: "r", "t1") - 조인 쿼리에서 사용
|
||||||
|
* @param startParamIndex 시작 파라미터 인덱스 (예: 1이면 $1부터 시작)
|
||||||
|
* @returns { whereClause: string, params: any[] }
|
||||||
|
*/
|
||||||
|
export function buildDataFilterWhereClause(
|
||||||
|
dataFilter: DataFilterConfig | undefined,
|
||||||
|
tableAlias?: string,
|
||||||
|
startParamIndex: number = 1
|
||||||
|
): { whereClause: string; params: any[] } {
|
||||||
|
if (!dataFilter || !dataFilter.enabled || !dataFilter.filters || dataFilter.filters.length === 0) {
|
||||||
|
return { whereClause: "", params: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = startParamIndex;
|
||||||
|
|
||||||
|
// 테이블 별칭이 있으면 "alias."를 붙이고, 없으면 그냥 컬럼명만
|
||||||
|
const getColumnRef = (colName: string) => {
|
||||||
|
return tableAlias ? `${tableAlias}."${colName}"` : `"${colName}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const filter of dataFilter.filters) {
|
||||||
|
const { columnName, operator, value } = filter;
|
||||||
|
|
||||||
|
if (!columnName) {
|
||||||
|
continue; // 컬럼명이 없으면 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnRef = getColumnRef(columnName);
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "equals":
|
||||||
|
conditions.push(`${columnRef} = $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "not_equals":
|
||||||
|
conditions.push(`${columnRef} != $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||||
|
conditions.push(`${columnRef} IN (${placeholders})`);
|
||||||
|
params.push(...value);
|
||||||
|
paramIndex += value.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "not_in":
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
|
||||||
|
conditions.push(`${columnRef} NOT IN (${placeholders})`);
|
||||||
|
params.push(...value);
|
||||||
|
paramIndex += value.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "contains":
|
||||||
|
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
||||||
|
params.push(`%${value}%`);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "starts_with":
|
||||||
|
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
||||||
|
params.push(`${value}%`);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ends_with":
|
||||||
|
conditions.push(`${columnRef} LIKE $${paramIndex}`);
|
||||||
|
params.push(`%${value}`);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "is_null":
|
||||||
|
conditions.push(`${columnRef} IS NULL`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "is_not_null":
|
||||||
|
conditions.push(`${columnRef} IS NOT NULL`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 알 수 없는 연산자는 무시
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return { whereClause: "", params: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchType에 따라 AND / OR 조합
|
||||||
|
const logicalOperator = dataFilter.matchType === "any" ? " OR " : " AND ";
|
||||||
|
const whereClause = `(${conditions.join(logicalOperator)})`;
|
||||||
|
|
||||||
|
return { whereClause, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 WHERE 절에 dataFilter 조건을 추가
|
||||||
|
* @param existingWhere 기존 WHERE 절 (예: "company_code = $1")
|
||||||
|
* @param existingParams 기존 파라미터 배열
|
||||||
|
* @param dataFilter 필터 설정
|
||||||
|
* @returns { whereClause: string, params: any[] }
|
||||||
|
*/
|
||||||
|
export function appendDataFilterToWhere(
|
||||||
|
existingWhere: string,
|
||||||
|
existingParams: any[],
|
||||||
|
dataFilter: DataFilterConfig | undefined
|
||||||
|
): { whereClause: string; params: any[] } {
|
||||||
|
const { whereClause: filterWhere, params: filterParams } = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
existingParams.length + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filterWhere) {
|
||||||
|
return { whereClause: existingWhere, params: existingParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWhere = existingWhere ? `${existingWhere} AND ${filterWhere}` : filterWhere;
|
||||||
|
const newParams = [...existingParams, ...filterParams];
|
||||||
|
|
||||||
|
return { whereClause: newWhere, params: newParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,634 @@
|
||||||
|
# 카테고리 메뉴별 컬럼 분리 구현 완료 보고서
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
**문제**: 같은 테이블의 같은 컬럼을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 지원 불가
|
||||||
|
|
||||||
|
**해결**: 가상 컬럼 분리 (Virtual Column Mapping) 방식 구현
|
||||||
|
|
||||||
|
**구현 날짜**: 2025-11-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 구현 완료 항목
|
||||||
|
|
||||||
|
### 1. 데이터베이스 스키마
|
||||||
|
|
||||||
|
#### `category_column_mapping` 테이블 생성 ✅
|
||||||
|
|
||||||
|
**파일**: `db/migrations/054_create_category_column_mapping.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE category_column_mapping (
|
||||||
|
mapping_id SERIAL PRIMARY KEY,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
logical_column_name VARCHAR(100) NOT NULL, -- 논리적 컬럼명
|
||||||
|
physical_column_name VARCHAR(100) NOT NULL, -- 물리적 컬럼명
|
||||||
|
menu_objid NUMERIC NOT NULL,
|
||||||
|
company_code VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
updated_by VARCHAR(50),
|
||||||
|
|
||||||
|
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**인덱스**:
|
||||||
|
- `idx_mapping_table_menu`: 조회 성능 최적화
|
||||||
|
- `idx_mapping_company`: 멀티테넌시 필터링
|
||||||
|
|
||||||
|
### 2. 백엔드 API 구현
|
||||||
|
|
||||||
|
#### 컨트롤러 (tableCategoryValueController.ts) ✅
|
||||||
|
|
||||||
|
구현된 API 엔드포인트:
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/table-categories/column-mapping/:tableName/:menuObjid` | 컬럼 매핑 조회 |
|
||||||
|
| POST | `/table-categories/column-mapping` | 컬럼 매핑 생성/수정 |
|
||||||
|
| GET | `/table-categories/logical-columns/:tableName/:menuObjid` | 논리적 컬럼 목록 조회 |
|
||||||
|
| DELETE | `/table-categories/column-mapping/:mappingId` | 컬럼 매핑 삭제 |
|
||||||
|
|
||||||
|
**멀티테넌시 지원**:
|
||||||
|
- ✅ 최고 관리자(`company_code = "*"`): 모든 매핑 조회/수정 가능
|
||||||
|
- ✅ 일반 회사: 자신의 매핑만 조회/수정 가능
|
||||||
|
|
||||||
|
#### 서비스 (tableCategoryValueService.ts) ✅
|
||||||
|
|
||||||
|
구현된 주요 메서드:
|
||||||
|
|
||||||
|
1. `getColumnMapping()`: 논리명 → 물리명 매핑 조회
|
||||||
|
2. `createColumnMapping()`: 컬럼 매핑 생성 (UPSERT)
|
||||||
|
3. `getLogicalColumns()`: 논리적 컬럼 목록 조회
|
||||||
|
4. `deleteColumnMapping()`: 컬럼 매핑 삭제
|
||||||
|
5. `convertToPhysicalColumns()`: 데이터 저장 시 자동 변환
|
||||||
|
|
||||||
|
**물리적 컬럼 존재 검증**:
|
||||||
|
```typescript
|
||||||
|
const columnCheckQuery = `
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**UPSERT 지원**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO category_column_mapping (...)
|
||||||
|
VALUES (...)
|
||||||
|
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
DO UPDATE SET ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 프론트엔드 API 클라이언트
|
||||||
|
|
||||||
|
#### `frontend/lib/api/tableCategoryValue.ts` ✅
|
||||||
|
|
||||||
|
구현된 함수:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 컬럼 매핑 조회
|
||||||
|
getColumnMapping(tableName: string, menuObjid: number)
|
||||||
|
|
||||||
|
// 논리적 컬럼 목록 조회
|
||||||
|
getLogicalColumns(tableName: string, menuObjid: number)
|
||||||
|
|
||||||
|
// 컬럼 매핑 생성
|
||||||
|
createColumnMapping(data: {
|
||||||
|
tableName: string;
|
||||||
|
logicalColumnName: string;
|
||||||
|
physicalColumnName: string;
|
||||||
|
menuObjid: number;
|
||||||
|
description?: string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 컬럼 매핑 삭제
|
||||||
|
deleteColumnMapping(mappingId: number)
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러 처리**:
|
||||||
|
- 네트워크 오류 시 `{ success: false, error: message }` 반환
|
||||||
|
- 콘솔 로그로 디버깅 정보 출력
|
||||||
|
|
||||||
|
### 4. 프론트엔드 UI 컴포넌트
|
||||||
|
|
||||||
|
#### `AddCategoryColumnDialog.tsx` ✅
|
||||||
|
|
||||||
|
**기능**:
|
||||||
|
- 논리적 컬럼명 입력
|
||||||
|
- 물리적 컬럼 선택 (드롭다운)
|
||||||
|
- 설명 입력 (선택사항)
|
||||||
|
- 적용 메뉴 표시 (읽기 전용)
|
||||||
|
|
||||||
|
**검증 로직**:
|
||||||
|
- 논리적 컬럼명 필수 체크
|
||||||
|
- 물리적 컬럼 선택 필수 체크
|
||||||
|
- 중복 매핑 방지
|
||||||
|
|
||||||
|
**shadcn/ui 스타일 가이드 준수**:
|
||||||
|
- ✅ 반응형 크기: `max-w-[95vw] sm:max-w-[500px]`
|
||||||
|
- ✅ 텍스트 크기: `text-xs sm:text-sm`
|
||||||
|
- ✅ 입력 필드: `h-8 sm:h-10`
|
||||||
|
- ✅ 버튼 레이아웃: `flex-1` (모바일), `flex-none` (데스크톱)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 작동 방식
|
||||||
|
|
||||||
|
### 예시: item_info.status 컬럼 분리
|
||||||
|
|
||||||
|
#### 1단계: 컬럼 매핑 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보 (menu_objid=103)
|
||||||
|
논리적 컬럼: status_stock
|
||||||
|
물리적 컬럼: status
|
||||||
|
카테고리: "정상", "대기", "품절"
|
||||||
|
|
||||||
|
영업관리 > 판매품목정보 (menu_objid=203)
|
||||||
|
논리적 컬럼: status_sales
|
||||||
|
물리적 컬럼: status
|
||||||
|
카테고리: "판매중", "판매중지", "품절"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2단계: 카테고리 값 저장
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_column_category_values 테이블
|
||||||
|
INSERT INTO table_column_category_values
|
||||||
|
(table_name, column_name, value_code, value_label, menu_objid)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'status_stock', 'NORMAL', '정상', 103),
|
||||||
|
('item_info', 'status_sales', 'ON_SALE', '판매중', 203);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3단계: 데이터 입력 (자동 변환)
|
||||||
|
|
||||||
|
**사용자 입력 (논리적 컬럼명)**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status_stock: "정상" // 논리적 컬럼명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**백엔드에서 자동 변환 (물리적 컬럼명)**:
|
||||||
|
```typescript
|
||||||
|
// convertToPhysicalColumns() 호출
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status: "정상" // 물리적 컬럼명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DB에 저장**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO item_info (item_name, status, company_code)
|
||||||
|
VALUES ('키보드', '정상', 'COMPANY_A');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4단계: 데이터 조회 (자동 매핑)
|
||||||
|
|
||||||
|
**DB 쿼리 결과**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status: "정상" // 물리적 컬럼명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 표시 (논리적 컬럼명으로 자동 매핑)**:
|
||||||
|
```typescript
|
||||||
|
// 기준정보 > 품목정보에서 보기
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status_stock: "정상" // 논리적 컬럼명
|
||||||
|
}
|
||||||
|
|
||||||
|
// 영업관리 > 판매품목정보에서 보기
|
||||||
|
{
|
||||||
|
item_name: "마우스",
|
||||||
|
status_sales: "판매중" // 다른 논리적 컬럼명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터 흐름도
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ 프론트엔드 (UI) │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ 기준정보 > 품목정보 │
|
||||||
|
│ - status_stock: "정상", "대기", "품절" │
|
||||||
|
│ │
|
||||||
|
│ 영업관리 > 판매품목정보 │
|
||||||
|
│ - status_sales: "판매중", "판매중지", "품절" │
|
||||||
|
└─────────────────┬──────────────────────────────────┘
|
||||||
|
│ (논리적 컬럼명 사용)
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ category_column_mapping (매핑 테이블) │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ status_stock → status (menu_objid=103) │
|
||||||
|
│ status_sales → status (menu_objid=203) │
|
||||||
|
└─────────────────┬──────────────────────────────────┘
|
||||||
|
│ (자동 변환)
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ item_info 테이블 (실제 DB) │
|
||||||
|
├────────────────────────────────────────────────────┤
|
||||||
|
│ item_name │ status (물리적 컬럼 - 하나만 존재) │
|
||||||
|
│ 키보드 │ 정상 │
|
||||||
|
│ 마우스 │ 판매중 │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 구현 효과
|
||||||
|
|
||||||
|
### 1. 문제 해결 ✅
|
||||||
|
|
||||||
|
**Before (문제)**:
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보: status = "정상", "대기", "품절"
|
||||||
|
영업관리 > 판매품목정보: status = "판매중", "판매중지", "품절"
|
||||||
|
→ 같은 컬럼이라 불가능!
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (해결)**:
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보: status_stock = "정상", "대기", "품절"
|
||||||
|
영업관리 > 판매품목정보: status_sales = "판매중", "판매중지", "품절"
|
||||||
|
→ 논리적으로 분리되어 가능!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 사용자 경험 개선
|
||||||
|
|
||||||
|
- ✅ 메뉴별 맞춤형 카테고리 관리
|
||||||
|
- ✅ 직관적인 논리적 컬럼명 사용
|
||||||
|
- ✅ 관리자가 UI에서 쉽게 설정 가능
|
||||||
|
- ✅ 불필요한 카테고리가 표시되지 않음
|
||||||
|
|
||||||
|
### 3. 시스템 안정성
|
||||||
|
|
||||||
|
- ✅ 데이터베이스 스키마 변경 최소화
|
||||||
|
- ✅ 기존 데이터 마이그레이션 불필요
|
||||||
|
- ✅ 물리적 컬럼 존재 검증으로 오류 방지
|
||||||
|
- ✅ 멀티테넌시 완벽 지원
|
||||||
|
|
||||||
|
### 4. 확장성
|
||||||
|
|
||||||
|
- ✅ 새로운 메뉴 추가 시 독립적인 카테고리 설정 가능
|
||||||
|
- ✅ 다른 컴포넌트에도 유사한 패턴 적용 가능
|
||||||
|
- ✅ 메뉴별 카테고리 통계 및 분석 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 사용 방법
|
||||||
|
|
||||||
|
### 관리자 작업 흐름
|
||||||
|
|
||||||
|
#### 1. 테이블 타입 관리 접속
|
||||||
|
```
|
||||||
|
메뉴: 시스템 관리 > 테이블 타입 관리
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 카테고리 컬럼 추가
|
||||||
|
```
|
||||||
|
1. 테이블 선택: item_info
|
||||||
|
2. "카테고리 컬럼 추가" 버튼 클릭
|
||||||
|
3. 실제 컬럼 선택: status
|
||||||
|
4. 논리적 컬럼명 입력: status_stock
|
||||||
|
5. 설명 입력: "재고 관리용 상태"
|
||||||
|
6. "추가" 버튼 클릭
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 카테고리 값 추가
|
||||||
|
```
|
||||||
|
1. 논리적 컬럼 선택: status_stock
|
||||||
|
2. "카테고리 값 추가" 버튼 클릭
|
||||||
|
3. 라벨 입력: "정상", "대기", "품절"
|
||||||
|
4. 각각 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 다른 메뉴에 대해 반복
|
||||||
|
```
|
||||||
|
1. 영업관리 > 판매품목정보 선택
|
||||||
|
2. 논리적 컬럼명: status_sales
|
||||||
|
3. 카테고리 값: "판매중", "판매중지", "품절"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사용자 화면에서 확인
|
||||||
|
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보
|
||||||
|
→ status_stock 필드가 표시됨
|
||||||
|
→ 드롭다운: "정상", "대기", "품절"
|
||||||
|
|
||||||
|
영업관리 > 판매품목정보
|
||||||
|
→ status_sales 필드가 표시됨
|
||||||
|
→ 드롭다운: "판매중", "판매중지", "품절"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 실행 방법
|
||||||
|
|
||||||
|
### 1. 데이터베이스 마이그레이션
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- pgAdmin 또는 psql에서 실행
|
||||||
|
\i db/migrations/054_create_category_column_mapping.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과 확인**:
|
||||||
|
```sql
|
||||||
|
-- 테이블 생성 확인
|
||||||
|
SELECT * FROM category_column_mapping LIMIT 5;
|
||||||
|
|
||||||
|
-- 인덱스 확인
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE tablename = 'category_column_mapping';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 백엔드 재시작 (불필요)
|
||||||
|
|
||||||
|
프로젝트 규칙에 따라 **백엔드 재시작 금지**
|
||||||
|
- 타입스크립트 파일 변경만으로 자동 반영됨
|
||||||
|
- 라우트 등록 완료됨
|
||||||
|
|
||||||
|
### 3. 프론트엔드 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드만 재시작 (필요 시)
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 시나리오
|
||||||
|
|
||||||
|
### 시나리오 1: 기본 매핑 생성
|
||||||
|
|
||||||
|
1. **테이블 타입 관리 접속**
|
||||||
|
2. **item_info 테이블 선택**
|
||||||
|
3. **"카테고리 컬럼 추가" 클릭**
|
||||||
|
4. **입력**:
|
||||||
|
- 실제 컬럼: `status`
|
||||||
|
- 논리적 컬럼명: `status_stock`
|
||||||
|
- 설명: "재고 관리용 상태"
|
||||||
|
5. **"추가" 클릭**
|
||||||
|
6. **확인**: 매핑이 생성되었는지 확인
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
- ✅ 성공 토스트 메시지 표시
|
||||||
|
- ✅ 논리적 컬럼 목록에 `status_stock` 추가됨
|
||||||
|
- ✅ DB에 매핑 레코드 생성
|
||||||
|
|
||||||
|
### 시나리오 2: 카테고리 값 추가
|
||||||
|
|
||||||
|
1. **논리적 컬럼 `status_stock` 선택**
|
||||||
|
2. **"카테고리 값 추가" 클릭**
|
||||||
|
3. **입력**:
|
||||||
|
- 라벨: `정상`
|
||||||
|
- 코드: 자동 생성
|
||||||
|
4. **"추가" 클릭**
|
||||||
|
5. **반복**: "대기", "품절" 추가
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
- ✅ 각 카테고리 값이 `status_stock` 컬럼에 연결됨
|
||||||
|
- ✅ `menu_objid`가 올바르게 설정됨
|
||||||
|
|
||||||
|
### 시나리오 3: 다른 메뉴에 다른 매핑
|
||||||
|
|
||||||
|
1. **영업관리 > 판매품목정보 메뉴 선택**
|
||||||
|
2. **item_info 테이블 선택**
|
||||||
|
3. **"카테고리 컬럼 추가" 클릭**
|
||||||
|
4. **입력**:
|
||||||
|
- 실제 컬럼: `status` (동일한 물리적 컬럼)
|
||||||
|
- 논리적 컬럼명: `status_sales` (다른 논리명)
|
||||||
|
- 설명: "판매 관리용 상태"
|
||||||
|
5. **카테고리 값 추가**: "판매중", "판매중지", "품절"
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
- ✅ 기준정보 > 품목정보: `status_stock` 표시
|
||||||
|
- ✅ 영업관리 > 판매품목정보: `status_sales` 표시
|
||||||
|
- ✅ 서로 다른 카테고리 값 리스트
|
||||||
|
|
||||||
|
### 시나리오 4: 데이터 저장 및 조회
|
||||||
|
|
||||||
|
1. **기준정보 > 품목정보에서 데이터 입력**
|
||||||
|
- 품목명: "키보드"
|
||||||
|
- status_stock: "정상"
|
||||||
|
2. **저장**
|
||||||
|
3. **DB 확인**:
|
||||||
|
```sql
|
||||||
|
SELECT item_name, status FROM item_info WHERE item_name = '키보드';
|
||||||
|
-- 결과: status = '정상' (물리적 컬럼)
|
||||||
|
```
|
||||||
|
4. **영업관리 > 판매품목정보에서 조회**
|
||||||
|
- status_sales 필드로 표시되지 않음 (다른 논리명)
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
- ✅ 논리적 컬럼명으로 입력
|
||||||
|
- ✅ 물리적 컬럼명으로 저장
|
||||||
|
- ✅ 메뉴별 독립적인 카테고리 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 주의사항
|
||||||
|
|
||||||
|
### 1. 기존 데이터 호환성
|
||||||
|
|
||||||
|
**기존에 물리적 컬럼명을 직접 사용하던 경우**:
|
||||||
|
- 마이그레이션 스크립트가 자동으로 기본 매핑 생성
|
||||||
|
- `logical_column_name = physical_column_name`으로 설정
|
||||||
|
- 기존 기능 유지됨
|
||||||
|
|
||||||
|
### 2. 성능 고려사항
|
||||||
|
|
||||||
|
**컬럼 매핑 조회**:
|
||||||
|
- 인덱스 활용으로 빠른 조회
|
||||||
|
- 첫 조회 후 캐싱 권장 (향후 개선)
|
||||||
|
|
||||||
|
**데이터 저장 시 변환**:
|
||||||
|
- 매번 매핑 조회 발생
|
||||||
|
- 트랜잭션 내에서 처리하여 성능 영향 최소화
|
||||||
|
|
||||||
|
### 3. 에러 처리
|
||||||
|
|
||||||
|
**물리적 컬럼 없음**:
|
||||||
|
```
|
||||||
|
에러 메시지: "테이블 item_info에 컬럼 status2가 존재하지 않습니다"
|
||||||
|
해결: 올바른 컬럼명 선택
|
||||||
|
```
|
||||||
|
|
||||||
|
**논리적 컬럼명 중복**:
|
||||||
|
```
|
||||||
|
에러 메시지: "중복된 키 값이 고유 제약조건을 위반합니다"
|
||||||
|
해결: 다른 논리적 컬럼명 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 디버깅 가이드
|
||||||
|
|
||||||
|
### 백엔드 로그 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로그 파일 위치
|
||||||
|
tail -f backend-node/logs/app.log
|
||||||
|
|
||||||
|
# 컬럼 매핑 조회 로그
|
||||||
|
"컬럼 매핑 조회" { tableName, menuObjid, companyCode }
|
||||||
|
|
||||||
|
# 컬럼 매핑 생성 로그
|
||||||
|
"컬럼 매핑 생성 완료" { mappingId, tableName, logicalColumnName }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 콘솔 확인
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 브라우저 개발자 도구 > 콘솔
|
||||||
|
"논리적 컬럼 목록 조회 시작: item_info, 103"
|
||||||
|
"컬럼 매핑 조회 완료: { status_stock: 'status' }"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터베이스 쿼리
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 모든 매핑 확인
|
||||||
|
SELECT * FROM category_column_mapping
|
||||||
|
WHERE table_name = 'item_info'
|
||||||
|
ORDER BY menu_objid, logical_column_name;
|
||||||
|
|
||||||
|
-- 특정 메뉴의 매핑
|
||||||
|
SELECT
|
||||||
|
logical_column_name,
|
||||||
|
physical_column_name,
|
||||||
|
description
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = 'item_info'
|
||||||
|
AND menu_objid = 103;
|
||||||
|
|
||||||
|
-- 카테고리 값과 매핑 조인
|
||||||
|
SELECT
|
||||||
|
ccm.logical_column_name,
|
||||||
|
ccm.physical_column_name,
|
||||||
|
tccv.value_label
|
||||||
|
FROM category_column_mapping ccm
|
||||||
|
JOIN table_column_category_values tccv
|
||||||
|
ON ccm.table_name = tccv.table_name
|
||||||
|
AND ccm.logical_column_name = tccv.column_name
|
||||||
|
AND ccm.menu_objid = tccv.menu_objid
|
||||||
|
WHERE ccm.table_name = 'item_info'
|
||||||
|
AND ccm.menu_objid = 103;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 추가 참고 자료
|
||||||
|
|
||||||
|
### 관련 문서
|
||||||
|
- [카테고리 메뉴스코프 개선 계획서](카테고리_메뉴스코프_개선_계획서.md)
|
||||||
|
- [카테고리 메뉴별 컬럼 분리 전략](카테고리_메뉴별_컬럼_분리_전략.md)
|
||||||
|
|
||||||
|
### 주요 파일 위치
|
||||||
|
- 마이그레이션: `db/migrations/054_create_category_column_mapping.sql`
|
||||||
|
- 컨트롤러: `backend-node/src/controllers/tableCategoryValueController.ts`
|
||||||
|
- 서비스: `backend-node/src/services/tableCategoryValueService.ts`
|
||||||
|
- 라우트: `backend-node/src/routes/tableCategoryValueRoutes.ts`
|
||||||
|
- API 클라이언트: `frontend/lib/api/tableCategoryValue.ts`
|
||||||
|
- UI 컴포넌트: `frontend/components/table-category/AddCategoryColumnDialog.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
### 개발 완료
|
||||||
|
- [x] `category_column_mapping` 테이블 생성
|
||||||
|
- [x] 백엔드: 컬럼 매핑 조회 API
|
||||||
|
- [x] 백엔드: 컬럼 매핑 생성 API
|
||||||
|
- [x] 백엔드: 논리적 컬럼 목록 조회 API
|
||||||
|
- [x] 백엔드: 컬럼 매핑 삭제 API
|
||||||
|
- [x] 백엔드: 데이터 저장 시 자동 변환 로직
|
||||||
|
- [x] 프론트엔드: API 클라이언트 함수
|
||||||
|
- [x] 프론트엔드: AddCategoryColumnDialog 컴포넌트
|
||||||
|
|
||||||
|
### 테스트 필요 (향후)
|
||||||
|
- [ ] 시나리오 1: 기본 매핑 생성
|
||||||
|
- [ ] 시나리오 2: 카테고리 값 추가
|
||||||
|
- [ ] 시나리오 3: 다른 메뉴에 다른 매핑
|
||||||
|
- [ ] 시나리오 4: 데이터 저장 및 조회
|
||||||
|
- [ ] 브라우저 테스트 (Chrome, Safari, Edge)
|
||||||
|
- [ ] 모바일 반응형 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 향후 개선 사항
|
||||||
|
|
||||||
|
### Phase 2 (권장)
|
||||||
|
1. **캐싱 메커니즘**
|
||||||
|
- 컬럼 매핑을 메모리에 캐싱
|
||||||
|
- 변경 시에만 재조회
|
||||||
|
- 성능 개선
|
||||||
|
|
||||||
|
2. **UI 개선**
|
||||||
|
- CategoryValueAddDialog에 논리적 컬럼 선택 기능 추가
|
||||||
|
- 매핑 관리 전용 UI 페이지
|
||||||
|
- 벌크 매핑 생성 기능
|
||||||
|
|
||||||
|
3. **관리 기능**
|
||||||
|
- 매핑 사용 현황 통계
|
||||||
|
- 미사용 매핑 자동 정리
|
||||||
|
- 매핑 복제 기능 (다른 메뉴로)
|
||||||
|
|
||||||
|
### Phase 3 (선택)
|
||||||
|
4. **고급 기능**
|
||||||
|
- 매핑 버전 관리
|
||||||
|
- 매핑 변경 이력 추적
|
||||||
|
- 매핑 검증 도구
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 문의 및 지원
|
||||||
|
|
||||||
|
**문제 발생 시**:
|
||||||
|
1. 로그 파일 확인 (backend-node/logs/app.log)
|
||||||
|
2. 브라우저 콘솔 확인 (개발자 도구)
|
||||||
|
3. 데이터베이스 쿼리로 직접 확인
|
||||||
|
|
||||||
|
**추가 개발 요청**:
|
||||||
|
- 새로운 기능 제안
|
||||||
|
- 버그 리포트
|
||||||
|
- 성능 개선 제안
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 결론
|
||||||
|
|
||||||
|
**가상 컬럼 분리 (Virtual Column Mapping) 방식**을 성공적으로 구현하여, 같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용할 수 있게 되었습니다.
|
||||||
|
|
||||||
|
**핵심 장점**:
|
||||||
|
- ✅ 데이터베이스 스키마 변경 최소화
|
||||||
|
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
|
||||||
|
- ✅ 자동 변환으로 개발자 부담 감소
|
||||||
|
- ✅ 멀티테넌시 완벽 지원
|
||||||
|
|
||||||
|
**실무 적용**:
|
||||||
|
- 테이블 타입 관리에서 바로 사용 가능
|
||||||
|
- 기존 기능과 완전히 호환
|
||||||
|
- 확장성 있는 아키텍처
|
||||||
|
|
||||||
|
이 시스템을 통해 사용자는 메뉴별로 맞춤형 카테고리를 쉽게 관리할 수 있으며, 관리자는 유연하게 카테고리를 설정할 수 있습니다.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,905 @@
|
||||||
|
# 카테고리 메뉴별 컬럼 분리 전략
|
||||||
|
|
||||||
|
## 1. 문제 정의
|
||||||
|
|
||||||
|
### 상황
|
||||||
|
같은 테이블(`item_info`)의 같은 컬럼(`status`)을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보 (menu_objid=103)
|
||||||
|
- status 컬럼: "정상", "대기", "품절"
|
||||||
|
|
||||||
|
영업관리 > 판매품목정보 (menu_objid=203)
|
||||||
|
- status 컬럼: "판매중", "판매중지", "품절"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 현재 문제점
|
||||||
|
- `table_column_category_values` 테이블 구조:
|
||||||
|
- `table_name` + `column_name` + `menu_objid` 조합으로 카테고리 값 저장
|
||||||
|
- 같은 테이블, 같은 컬럼, 다른 메뉴 = 서로 다른 카테고리 값 사용 가능
|
||||||
|
- **하지만 실제 DB 컬럼은 하나뿐!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 해결 방안 비교
|
||||||
|
|
||||||
|
### 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) ⭐ **추천**
|
||||||
|
|
||||||
|
**개념**: 물리적으로는 같은 `status` 컬럼이지만, 메뉴별로 **논리적으로 다른 컬럼명**을 사용
|
||||||
|
|
||||||
|
#### 장점
|
||||||
|
- ✅ 데이터베이스 스키마 변경 불필요
|
||||||
|
- ✅ 기존 데이터 마이그레이션 불필요
|
||||||
|
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
|
||||||
|
- ✅ 유연한 확장 가능
|
||||||
|
|
||||||
|
#### 단점
|
||||||
|
- ⚠️ 컬럼 매핑 관리 필요 (논리명 → 물리명)
|
||||||
|
- ⚠️ UI에서 가상 컬럼 개념 이해 필요
|
||||||
|
|
||||||
|
#### 구현 방식
|
||||||
|
|
||||||
|
**데이터베이스**:
|
||||||
|
```sql
|
||||||
|
-- table_column_category_values 테이블 사용
|
||||||
|
-- column_name을 "논리적 컬럼명"으로 저장
|
||||||
|
|
||||||
|
-- 기준정보 > 품목정보
|
||||||
|
INSERT INTO table_column_category_values
|
||||||
|
(table_name, column_name, value_code, value_label, menu_objid)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'status_stock', 'NORMAL', '정상', 103),
|
||||||
|
('item_info', 'status_stock', 'PENDING', '대기', 103),
|
||||||
|
('item_info', 'status_stock', 'OUT_OF_STOCK', '품절', 103);
|
||||||
|
|
||||||
|
-- 영업관리 > 판매품목정보
|
||||||
|
INSERT INTO table_column_category_values
|
||||||
|
(table_name, column_name, value_code, value_label, menu_objid)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'status_sales', 'ON_SALE', '판매중', 203),
|
||||||
|
('item_info', 'status_sales', 'DISCONTINUED', '판매중지', 203),
|
||||||
|
('item_info', 'status_sales', 'OUT_OF_STOCK', '품절', 203);
|
||||||
|
```
|
||||||
|
|
||||||
|
**컬럼 매핑 테이블** (새로 생성):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE category_column_mapping (
|
||||||
|
mapping_id SERIAL PRIMARY KEY,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
logical_column_name VARCHAR(100) NOT NULL, -- status_stock, status_sales
|
||||||
|
physical_column_name VARCHAR(100) NOT NULL, -- status (실제 DB 컬럼)
|
||||||
|
menu_objid NUMERIC NOT NULL,
|
||||||
|
company_code VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 예시 데이터
|
||||||
|
INSERT INTO category_column_mapping
|
||||||
|
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'status_stock', 'status', 103, 'COMPANY_A'),
|
||||||
|
('item_info', 'status_sales', 'status', 203, 'COMPANY_A');
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 UI**:
|
||||||
|
```typescript
|
||||||
|
// 테이블 타입 관리에서 카테고리 컬럼 추가 시
|
||||||
|
function AddCategoryColumn({ tableName, menuObjid }: Props) {
|
||||||
|
const [logicalColumnName, setLogicalColumnName] = useState("");
|
||||||
|
const [physicalColumnName, setPhysicalColumnName] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>카테고리 컬럼 추가</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 실제 DB 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>실제 컬럼 (물리적)</Label>
|
||||||
|
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="status">status</SelectItem>
|
||||||
|
<SelectItem value="category_type">category_type</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 논리적 컬럼명 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label>논리적 컬럼명 (메뉴별 식별용)</Label>
|
||||||
|
<Input
|
||||||
|
value={logicalColumnName}
|
||||||
|
onChange={(e) => setLogicalColumnName(e.target.value)}
|
||||||
|
placeholder="예: status_stock, status_sales"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
이 메뉴에서만 사용할 고유한 이름을 입력하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 적용할 메뉴 표시 (읽기 전용) */}
|
||||||
|
<div>
|
||||||
|
<Label>적용 메뉴</Label>
|
||||||
|
<Input value={currentMenuName} disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleSave}>저장</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**데이터 저장 시 매핑 적용**:
|
||||||
|
```typescript
|
||||||
|
// InteractiveScreenViewer.tsx
|
||||||
|
async function saveData(formData: any) {
|
||||||
|
const companyCode = user.companyCode;
|
||||||
|
const menuObjid = screenConfig.menuObjid;
|
||||||
|
|
||||||
|
// 논리적 컬럼명 → 물리적 컬럼명 매핑
|
||||||
|
const mappingResponse = await apiClient.get(
|
||||||
|
`/api/categories/column-mapping/${tableName}/${menuObjid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnMapping = mappingResponse.data.data; // { status_sales: "status" }
|
||||||
|
|
||||||
|
// formData를 물리적 컬럼명으로 변환
|
||||||
|
const physicalData = {};
|
||||||
|
for (const [logicalCol, value] of Object.entries(formData)) {
|
||||||
|
const physicalCol = columnMapping[logicalCol] || logicalCol;
|
||||||
|
physicalData[physicalCol] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 DB 저장
|
||||||
|
await apiClient.post(`/api/data/${tableName}`, physicalData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 방안 B: 물리적 컬럼 분리 (Physical Column Separation)
|
||||||
|
|
||||||
|
**개념**: 실제로 테이블에 `status_stock`, `status_sales` 같은 별도 컬럼 생성
|
||||||
|
|
||||||
|
#### 장점
|
||||||
|
- ✅ 단순하고 직관적
|
||||||
|
- ✅ 매핑 로직 불필요
|
||||||
|
|
||||||
|
#### 단점
|
||||||
|
- ❌ 데이터베이스 스키마 변경 필요
|
||||||
|
- ❌ 기존 데이터 마이그레이션 필요
|
||||||
|
- ❌ 컬럼 추가마다 DDL 실행 필요
|
||||||
|
- ❌ 유연성 부족
|
||||||
|
|
||||||
|
#### 구현 방식
|
||||||
|
|
||||||
|
**데이터베이스 스키마 변경**:
|
||||||
|
```sql
|
||||||
|
-- item_info 테이블에 컬럼 추가
|
||||||
|
ALTER TABLE item_info
|
||||||
|
ADD COLUMN status_stock VARCHAR(50),
|
||||||
|
ADD COLUMN status_sales VARCHAR(50);
|
||||||
|
|
||||||
|
-- 기존 데이터 마이그레이션
|
||||||
|
UPDATE item_info
|
||||||
|
SET
|
||||||
|
status_stock = status, -- 기본값으로 복사
|
||||||
|
status_sales = status;
|
||||||
|
```
|
||||||
|
|
||||||
|
**단점이 명확함**:
|
||||||
|
- 메뉴가 추가될 때마다 컬럼 추가 필요
|
||||||
|
- 테이블 구조가 복잡해짐
|
||||||
|
- 유지보수 어려움
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 방안 C: 현재 구조 유지 (Same Column, Different Values)
|
||||||
|
|
||||||
|
**개념**: 같은 `status` 컬럼을 사용하되, 메뉴별로 다른 카테고리 값만 표시
|
||||||
|
|
||||||
|
#### 장점
|
||||||
|
- ✅ 가장 단순한 구조
|
||||||
|
- ✅ 추가 개발 불필요
|
||||||
|
|
||||||
|
#### 단점
|
||||||
|
- ❌ **데이터 정합성 문제**: 실제 DB에는 하나의 값만 저장 가능
|
||||||
|
- ❌ 메뉴별로 다른 값을 저장할 수 없음
|
||||||
|
|
||||||
|
#### 예시 (문제 발생)
|
||||||
|
```
|
||||||
|
item_info 테이블의 실제 데이터:
|
||||||
|
item_id | status
|
||||||
|
--------|--------
|
||||||
|
1 | "NORMAL" (기준정보에서 입력)
|
||||||
|
2 | "ON_SALE" (영업관리에서 입력)
|
||||||
|
|
||||||
|
→ 기준정보에서 item_id=2를 볼 때 "ON_SALE"이 뭔지 모름 (정의되지 않은 값)
|
||||||
|
```
|
||||||
|
|
||||||
|
**결론**: 이 방안은 **불가능**합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 최종 추천 방안
|
||||||
|
|
||||||
|
### 🏆 방안 A: 가상 컬럼 분리 (Virtual Column Mapping)
|
||||||
|
|
||||||
|
**이유**:
|
||||||
|
1. 데이터베이스 스키마 변경 없음
|
||||||
|
2. 메뉴별 완전히 독립적인 카테고리 관리
|
||||||
|
3. 실제 데이터 저장 시 물리적 컬럼으로 자동 매핑
|
||||||
|
4. 확장성과 유연성 확보
|
||||||
|
|
||||||
|
**핵심 개념**:
|
||||||
|
- **논리적 컬럼명**: UI와 카테고리 설정에서 사용 (`status_stock`, `status_sales`)
|
||||||
|
- **물리적 컬럼명**: 실제 DB 저장 시 사용 (`status`)
|
||||||
|
- **매핑 테이블**: 논리명과 물리명을 연결
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 계획
|
||||||
|
|
||||||
|
### Phase 1: 데이터베이스 스키마 추가
|
||||||
|
|
||||||
|
#### 4.1 컬럼 매핑 테이블 생성
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- db/migrations/054_create_category_column_mapping.sql
|
||||||
|
|
||||||
|
CREATE TABLE category_column_mapping (
|
||||||
|
mapping_id SERIAL PRIMARY KEY,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
logical_column_name VARCHAR(100) NOT NULL COMMENT '논리적 컬럼명 (UI에서 사용)',
|
||||||
|
physical_column_name VARCHAR(100) NOT NULL COMMENT '물리적 컬럼명 (실제 DB 컬럼)',
|
||||||
|
menu_objid NUMERIC NOT NULL,
|
||||||
|
company_code VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
updated_by VARCHAR(50),
|
||||||
|
|
||||||
|
CONSTRAINT fk_mapping_company FOREIGN KEY (company_code)
|
||||||
|
REFERENCES company_info(company_code),
|
||||||
|
CONSTRAINT fk_mapping_menu FOREIGN KEY (menu_objid)
|
||||||
|
REFERENCES menu_info(objid),
|
||||||
|
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_mapping_table_menu ON category_column_mapping(table_name, menu_objid);
|
||||||
|
CREATE INDEX idx_mapping_company ON category_column_mapping(company_code);
|
||||||
|
|
||||||
|
COMMENT ON TABLE category_column_mapping IS '카테고리 컬럼의 논리명-물리명 매핑';
|
||||||
|
COMMENT ON COLUMN category_column_mapping.logical_column_name IS '메뉴별 카테고리 컬럼의 논리적 이름 (예: status_stock)';
|
||||||
|
COMMENT ON COLUMN category_column_mapping.physical_column_name IS '실제 테이블의 물리적 컬럼 이름 (예: status)';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 기존 카테고리 컬럼 마이그레이션 (선택사항)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성
|
||||||
|
INSERT INTO category_column_mapping
|
||||||
|
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
|
||||||
|
SELECT DISTINCT
|
||||||
|
table_name,
|
||||||
|
column_name, -- 기존에는 논리명=물리명
|
||||||
|
column_name,
|
||||||
|
menu_objid,
|
||||||
|
company_code
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE menu_objid IS NOT NULL
|
||||||
|
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 백엔드 API 구현
|
||||||
|
|
||||||
|
#### 2.1 컬럼 매핑 API
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/categoryController.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 메뉴별 컬럼 매핑 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @returns { logical_column: physical_column } 매핑
|
||||||
|
*/
|
||||||
|
export async function getColumnMapping(req: Request, res: Response) {
|
||||||
|
const { tableName, menuObjid } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
logical_column_name,
|
||||||
|
physical_column_name,
|
||||||
|
description
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
AND company_code = $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [tableName, menuObjid, companyCode]);
|
||||||
|
|
||||||
|
// { status_stock: "status", status_sales: "status" } 형태로 변환
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
result.rows.forEach((row) => {
|
||||||
|
mapping[row.logical_column_name] = row.physical_column_name;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 조회", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
mappingCount: Object.keys(mapping).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: mapping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 생성
|
||||||
|
*/
|
||||||
|
export async function createColumnMapping(req: Request, res: Response) {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
description,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 물리적 컬럼이 실제로 존재하는지 확인
|
||||||
|
const columnCheckQuery = `
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const columnCheck = await pool.query(columnCheckQuery, [tableName, physicalColumnName]);
|
||||||
|
|
||||||
|
if (columnCheck.rowCount === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 저장
|
||||||
|
const query = `
|
||||||
|
INSERT INTO category_column_mapping (
|
||||||
|
table_name,
|
||||||
|
logical_column_name,
|
||||||
|
physical_column_name,
|
||||||
|
menu_objid,
|
||||||
|
company_code,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
physical_column_name = EXCLUDED.physical_column_name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = EXCLUDED.updated_by
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
description || null,
|
||||||
|
req.user!.userId,
|
||||||
|
req.user!.userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("컬럼 매핑 생성", {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**라우트 등록**:
|
||||||
|
```typescript
|
||||||
|
router.get("/api/categories/column-mapping/:tableName/:menuObjid", authenticate, getColumnMapping);
|
||||||
|
router.post("/api/categories/column-mapping", authenticate, createColumnMapping);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 데이터 저장 시 매핑 적용
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/dataController.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환
|
||||||
|
*/
|
||||||
|
export async function saveData(req: Request, res: Response) {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { menuObjid, data } = req.body;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
// 1. 컬럼 매핑 조회
|
||||||
|
const mappingQuery = `
|
||||||
|
SELECT logical_column_name, physical_column_name
|
||||||
|
FROM category_column_mapping
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
AND company_code = $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const mappingResult = await pool.query(mappingQuery, [tableName, menuObjid, companyCode]);
|
||||||
|
|
||||||
|
const mapping: Record<string, string> = {};
|
||||||
|
mappingResult.rows.forEach((row) => {
|
||||||
|
mapping[row.logical_column_name] = row.physical_column_name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 논리적 컬럼명 → 물리적 컬럼명 변환
|
||||||
|
const physicalData: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
|
||||||
|
physicalData[physicalColumn] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 실제 데이터 저장
|
||||||
|
const columns = Object.keys(physicalData);
|
||||||
|
const values = Object.values(physicalData);
|
||||||
|
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO ${tableName} (${columns.join(", ")}, company_code)
|
||||||
|
VALUES (${placeholders}, $${columns.length + 1})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(insertQuery, [...values, companyCode]);
|
||||||
|
|
||||||
|
logger.info("데이터 저장 (컬럼 매핑 적용)", {
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
logicalColumns: Object.keys(data),
|
||||||
|
physicalColumns: columns,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: 프론트엔드 UI 구현
|
||||||
|
|
||||||
|
#### 3.1 테이블 타입 관리: 논리적 컬럼 추가
|
||||||
|
|
||||||
|
**파일**: `frontend/components/admin/table-type-management/AddCategoryColumnDialog.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AddCategoryColumnDialogProps {
|
||||||
|
tableName: string;
|
||||||
|
menuObjid: number;
|
||||||
|
menuName: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCategoryColumnDialog({
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
menuName,
|
||||||
|
onSuccess,
|
||||||
|
}: AddCategoryColumnDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
|
||||||
|
const [logicalColumnName, setLogicalColumnName] = useState("");
|
||||||
|
const [physicalColumnName, setPhysicalColumnName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 테이블의 실제 컬럼 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
async function loadColumns() {
|
||||||
|
const response = await apiClient.get(`/api/tables/${tableName}/columns`);
|
||||||
|
if (response.data.success) {
|
||||||
|
setPhysicalColumns(response.data.data.map((col: any) => col.column_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadColumns();
|
||||||
|
}
|
||||||
|
}, [open, tableName]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 1. 컬럼 매핑 생성
|
||||||
|
const mappingResponse = await apiClient.post("/api/categories/column-mapping", {
|
||||||
|
tableName,
|
||||||
|
logicalColumnName,
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mappingResponse.data.success) {
|
||||||
|
toast.error("컬럼 매핑 생성 실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("논리적 컬럼이 추가되었습니다");
|
||||||
|
setOpen(false);
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
카테고리 컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
카테고리 컬럼 추가
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다
|
||||||
|
</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">
|
||||||
|
실제 컬럼 (물리적) *
|
||||||
|
</Label>
|
||||||
|
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{physicalColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
테이블의 실제 컬럼명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 논리적 컬럼명 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
논리적 컬럼명 (메뉴별 식별용) *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={logicalColumnName}
|
||||||
|
onChange={(e) => setLogicalColumnName(e.target.value)}
|
||||||
|
placeholder="예: status_stock, status_sales"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 설명 (선택사항) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!logicalColumnName || !physicalColumnName}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 카테고리 값 추가 시 논리적 컬럼 사용
|
||||||
|
|
||||||
|
**파일**: `frontend/components/admin/table-type-management/CategoryValueEditor.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function CategoryValueEditor({
|
||||||
|
tableName,
|
||||||
|
menuObjid,
|
||||||
|
onSuccess,
|
||||||
|
}: Props) {
|
||||||
|
const [logicalColumns, setLogicalColumns] = useState<Array<{
|
||||||
|
logical_column_name: string;
|
||||||
|
physical_column_name: string;
|
||||||
|
description: string;
|
||||||
|
}>>([]);
|
||||||
|
const [selectedLogicalColumn, setSelectedLogicalColumn] = useState("");
|
||||||
|
const [valueCode, setValueCode] = useState("");
|
||||||
|
const [valueLabel, setValueLabel] = useState("");
|
||||||
|
|
||||||
|
// 논리적 컬럼 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadLogicalColumns() {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/categories/logical-columns/${tableName}/${menuObjid}`
|
||||||
|
);
|
||||||
|
if (response.data.success) {
|
||||||
|
setLogicalColumns(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadLogicalColumns();
|
||||||
|
}, [tableName, menuObjid]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await apiClient.post("/api/categories/values", {
|
||||||
|
tableName,
|
||||||
|
columnName: selectedLogicalColumn, // 논리적 컬럼명 저장
|
||||||
|
valueCode,
|
||||||
|
valueLabel,
|
||||||
|
menuObjid,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("카테고리 값이 추가되었습니다");
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 논리적 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>컬럼 선택</Label>
|
||||||
|
<Select value={selectedLogicalColumn} onValueChange={setSelectedLogicalColumn}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카테고리 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{logicalColumns.map((col) => (
|
||||||
|
<SelectItem key={col.logical_column_name} value={col.logical_column_name}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{col.logical_column_name}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
실제 컬럼: {col.physical_column_name}
|
||||||
|
</span>
|
||||||
|
{col.description && (
|
||||||
|
<span className="text-xs text-gray-400">{col.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 값 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label>코드</Label>
|
||||||
|
<Input value={valueCode} onChange={(e) => setValueCode(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>라벨</Label>
|
||||||
|
<Input value={valueLabel} onChange={(e) => setValueLabel(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave}>저장</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 데이터 저장 시 매핑 적용
|
||||||
|
|
||||||
|
**파일**: `frontend/components/screen/InteractiveScreenViewer.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function saveFormData(formData: Record<string, any>) {
|
||||||
|
const menuObjid = screenConfig.menuObjid;
|
||||||
|
const tableName = screenConfig.tableName;
|
||||||
|
|
||||||
|
// 백엔드에서 자동으로 논리명 → 물리명 변환
|
||||||
|
const response = await apiClient.post(`/api/data/${tableName}`, {
|
||||||
|
menuObjid,
|
||||||
|
data: formData, // 논리적 컬럼명으로 전달
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success("저장되었습니다");
|
||||||
|
} else {
|
||||||
|
toast.error("저장 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사용 예시
|
||||||
|
|
||||||
|
### 예시 1: 품목 상태 컬럼 분리
|
||||||
|
|
||||||
|
**상황**: `item_info.status` 컬럼을 두 메뉴에서 다르게 사용
|
||||||
|
|
||||||
|
#### 1단계: 논리적 컬럼 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
기준정보 > 품목정보 (menu_objid=103)
|
||||||
|
- 논리적 컬럼명: status_stock
|
||||||
|
- 물리적 컬럼명: status
|
||||||
|
- 카테고리 값: 정상, 대기, 품절
|
||||||
|
|
||||||
|
영업관리 > 판매품목정보 (menu_objid=203)
|
||||||
|
- 논리적 컬럼명: status_sales
|
||||||
|
- 물리적 컬럼명: status
|
||||||
|
- 카테고리 값: 판매중, 판매중지, 품절
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2단계: 데이터 입력
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기준정보 > 품목정보에서 입력
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status_stock: "정상", // 논리적 컬럼명
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에 저장될 때
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status: "정상", // 물리적 컬럼명으로 자동 변환
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 영업관리 > 판매품목정보에서 입력
|
||||||
|
{
|
||||||
|
item_name: "마우스",
|
||||||
|
status_sales: "판매중", // 논리적 컬럼명
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에 저장될 때
|
||||||
|
{
|
||||||
|
item_name: "마우스",
|
||||||
|
status: "판매중", // 물리적 컬럼명으로 자동 변환
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3단계: 데이터 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기준정보 > 품목정보에서 조회
|
||||||
|
SELECT
|
||||||
|
item_id,
|
||||||
|
item_name,
|
||||||
|
status -- 물리적 컬럼
|
||||||
|
FROM item_info
|
||||||
|
WHERE company_code = 'COMPANY_A';
|
||||||
|
|
||||||
|
// 프론트엔드에서 표시할 때 논리적 컬럼명으로 매핑
|
||||||
|
{
|
||||||
|
item_name: "키보드",
|
||||||
|
status_stock: "정상", // UI에서는 논리명 사용
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 체크리스트
|
||||||
|
|
||||||
|
### 개발 단계
|
||||||
|
- [ ] `category_column_mapping` 테이블 생성
|
||||||
|
- [ ] 백엔드: 컬럼 매핑 조회 API
|
||||||
|
- [ ] 백엔드: 컬럼 매핑 생성 API
|
||||||
|
- [ ] 백엔드: 데이터 저장 시 논리명 → 물리명 변환 로직
|
||||||
|
- [ ] 프론트엔드: 논리적 컬럼 추가 UI
|
||||||
|
- [ ] 프론트엔드: 카테고리 값 추가 시 논리적 컬럼 선택
|
||||||
|
- [ ] 프론트엔드: 데이터 저장 시 논리적 컬럼명 사용
|
||||||
|
|
||||||
|
### 테스트 단계
|
||||||
|
- [ ] 같은 물리적 컬럼에 여러 논리적 컬럼 생성
|
||||||
|
- [ ] 메뉴별로 다른 카테고리 값 표시
|
||||||
|
- [ ] 데이터 저장 시 올바른 물리적 컬럼에 저장
|
||||||
|
- [ ] 데이터 조회 시 논리적 컬럼명으로 매핑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 장점 요약
|
||||||
|
|
||||||
|
### 데이터베이스
|
||||||
|
- ✅ 스키마 변경 최소화
|
||||||
|
- ✅ 기존 데이터 마이그레이션 불필요
|
||||||
|
- ✅ 유연한 컬럼 관리
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
- ✅ 메뉴별 맞춤형 카테고리 관리
|
||||||
|
- ✅ 직관적인 논리적 컬럼명 사용
|
||||||
|
- ✅ 관리자가 쉽게 설정 가능
|
||||||
|
|
||||||
|
### 개발
|
||||||
|
- ✅ 백엔드에서 자동 매핑 처리
|
||||||
|
- ✅ 프론트엔드는 논리적 컬럼명만 사용
|
||||||
|
- ✅ 확장 가능한 아키텍처
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
**같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용하려면 "가상 컬럼 분리 (Virtual Column Mapping)" 방식이 최적입니다.**
|
||||||
|
|
||||||
|
- 논리적 컬럼명으로 메뉴별 카테고리 독립성 확보
|
||||||
|
- 물리적 컬럼명으로 실제 데이터 저장
|
||||||
|
- 매핑 테이블로 유연한 관리
|
||||||
|
|
||||||
|
이 방식은 데이터베이스 변경을 최소화하면서도 메뉴별로 완전히 독립적인 카테고리 관리를 가능하게 합니다.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,673 @@
|
||||||
|
# 카테고리 메뉴 스코프 개선 계획서
|
||||||
|
|
||||||
|
## 1. 문제 정의
|
||||||
|
|
||||||
|
### 현재 문제점
|
||||||
|
**카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가**
|
||||||
|
|
||||||
|
#### 구체적 상황
|
||||||
|
- `기준정보 > 품목정보` (item_info 테이블 사용)
|
||||||
|
- `영업관리 > 판매품목정보` (item_info 테이블 사용)
|
||||||
|
- 두 메뉴가 같은 테이블(`item_info`)을 사용하지만, 표시되어야 할 카테고리는 달라야 함
|
||||||
|
|
||||||
|
#### 현재 로직의 문제
|
||||||
|
```typescript
|
||||||
|
// 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회
|
||||||
|
const siblings = await getSiblingMenus(currentMenuId);
|
||||||
|
const categories = await getCategoriesFromTables(siblings.tables);
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**:
|
||||||
|
- `기준정보 > 품목정보`에서 보이지 않아야 할 카테고리가 표시됨
|
||||||
|
- `영업관리 > 판매품목정보`에서만 보여야 할 카테고리가 `기준정보`에도 표시됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 해결 방안
|
||||||
|
|
||||||
|
### 핵심 아이디어
|
||||||
|
**카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링**
|
||||||
|
|
||||||
|
### 2.1 데이터베이스 구조 (이미 준비됨)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_column_category_values 테이블에 이미 menu_objid 컬럼 존재
|
||||||
|
SELECT * FROM table_column_category_values;
|
||||||
|
|
||||||
|
-- 컬럼 구조
|
||||||
|
-- value_id: PK
|
||||||
|
-- table_name: 테이블명
|
||||||
|
-- column_name: 컬럼명
|
||||||
|
-- value_code: 카테고리 코드
|
||||||
|
-- value_label: 카테고리 라벨
|
||||||
|
-- menu_objid: 2레벨 메뉴 OBJID (핵심!)
|
||||||
|
-- company_code: 회사 코드
|
||||||
|
-- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 메뉴 계층 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
1레벨 메뉴 (기준정보, objid=1)
|
||||||
|
├── 2레벨 메뉴 (회사정보, objid=101)
|
||||||
|
├── 2레벨 메뉴 (부서관리, objid=102)
|
||||||
|
└── 2레벨 메뉴 (품목정보, objid=103) ← 여기에 item_info 테이블 사용
|
||||||
|
|
||||||
|
1레벨 메뉴 (영업관리, objid=2)
|
||||||
|
├── 2레벨 메뉴 (견적관리, objid=201)
|
||||||
|
├── 2레벨 메뉴 (수주관리, objid=202)
|
||||||
|
└── 2레벨 메뉴 (판매품목정보, objid=203) ← 여기도 item_info 테이블 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 카테고리 값 저장 방식
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 기준정보 > 품목정보에서 사용할 카테고리
|
||||||
|
INSERT INTO table_column_category_values
|
||||||
|
(table_name, column_name, value_code, value_label, menu_objid, company_code)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'category_type', 'STOCK_ITEM', '재고품목', 103, 'COMPANY_A'),
|
||||||
|
('item_info', 'category_type', 'ASSET', '자산', 103, 'COMPANY_A');
|
||||||
|
|
||||||
|
-- 영업관리 > 판매품목정보에서 사용할 카테고리
|
||||||
|
INSERT INTO table_column_category_values
|
||||||
|
(table_name, column_name, value_code, value_label, menu_objid, company_code)
|
||||||
|
VALUES
|
||||||
|
('item_info', 'category_type', 'SALES_ITEM', '판매품목', 203, 'COMPANY_A'),
|
||||||
|
('item_info', 'category_type', 'SERVICE', '서비스', 203, 'COMPANY_A');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 단계
|
||||||
|
|
||||||
|
### Phase 1: 백엔드 API 수정
|
||||||
|
|
||||||
|
#### 3.1 카테고리 조회 API 개선
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/categoryController.ts`
|
||||||
|
|
||||||
|
**현재 로직**:
|
||||||
|
```typescript
|
||||||
|
export async function getCategoryColumns(req: Request, res: Response) {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
// 형제 메뉴들의 테이블에서 카테고리 컬럼 조회
|
||||||
|
const siblings = await getSiblingMenuTables(menuObjid);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
value_code,
|
||||||
|
value_label
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name IN (${siblings.join(',')})
|
||||||
|
AND company_code = $1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선된 로직**:
|
||||||
|
```typescript
|
||||||
|
export async function getCategoryColumns(req: Request, res: Response) {
|
||||||
|
const { tableName, menuObjid } = req.params; // menuObjid 추가
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
// 2레벨 메뉴 OBJID 찾기
|
||||||
|
const topLevelMenuId = await getTopLevelMenuId(menuObjid);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
value_code,
|
||||||
|
value_label,
|
||||||
|
value_order,
|
||||||
|
parent_value_id,
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND menu_objid = $2
|
||||||
|
AND company_code = $3
|
||||||
|
AND is_active = true
|
||||||
|
ORDER BY value_order, value_label
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [tableName, topLevelMenuId, companyCode]);
|
||||||
|
|
||||||
|
logger.info("카테고리 컬럼 조회 (메뉴 스코프)", {
|
||||||
|
tableName,
|
||||||
|
menuObjid: topLevelMenuId,
|
||||||
|
companyCode,
|
||||||
|
categoryCount: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 2레벨 메뉴 OBJID 찾기 함수
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 현재 메뉴의 최상위(2레벨) 메뉴 OBJID 찾기
|
||||||
|
*
|
||||||
|
* @param menuObjid - 현재 메뉴 OBJID
|
||||||
|
* @returns 2레벨 메뉴 OBJID
|
||||||
|
*/
|
||||||
|
async function getTopLevelMenuId(menuObjid: number): Promise<number> {
|
||||||
|
const query = `
|
||||||
|
WITH RECURSIVE menu_hierarchy AS (
|
||||||
|
-- 현재 메뉴
|
||||||
|
SELECT
|
||||||
|
objid,
|
||||||
|
parent_obj_id,
|
||||||
|
menu_type,
|
||||||
|
1 as level
|
||||||
|
FROM menu_info
|
||||||
|
WHERE objid = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 부모 메뉴들 재귀 조회
|
||||||
|
SELECT
|
||||||
|
m.objid,
|
||||||
|
m.parent_obj_id,
|
||||||
|
m.menu_type,
|
||||||
|
mh.level + 1
|
||||||
|
FROM menu_info m
|
||||||
|
JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
|
||||||
|
)
|
||||||
|
SELECT objid
|
||||||
|
FROM menu_hierarchy
|
||||||
|
WHERE parent_obj_id IS NULL OR menu_type = 1 -- 1레벨 메뉴의 자식 = 2레벨
|
||||||
|
ORDER BY level DESC
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [menuObjid]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error(`메뉴를 찾을 수 없습니다: ${menuObjid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0].objid;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 카테고리 값 생성 API 개선
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/categoryController.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createCategoryValue(req: Request, res: Response) {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
valueCode,
|
||||||
|
valueLabel,
|
||||||
|
menuObjid, // 필수로 추가
|
||||||
|
parentValueId,
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 입력 검증
|
||||||
|
if (!tableName || !columnName || !valueCode || !valueLabel || !menuObjid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO table_column_category_values (
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
value_code,
|
||||||
|
value_label,
|
||||||
|
menu_objid,
|
||||||
|
parent_value_id,
|
||||||
|
depth,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
company_code,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
valueCode,
|
||||||
|
valueLabel,
|
||||||
|
menuObjid,
|
||||||
|
parentValueId || null,
|
||||||
|
depth || 1,
|
||||||
|
description || null,
|
||||||
|
color || null,
|
||||||
|
icon || null,
|
||||||
|
companyCode,
|
||||||
|
req.user!.userId,
|
||||||
|
req.user!.userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("카테고리 값 생성", {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
valueCode,
|
||||||
|
menuObjid,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 프론트엔드 - 테이블 타입 관리 UI 개선
|
||||||
|
|
||||||
|
#### 2.1 테이블 타입 관리 컴포넌트 수정
|
||||||
|
|
||||||
|
**파일**: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
|
||||||
|
|
||||||
|
**추가할 기능**:
|
||||||
|
1. 카테고리 컬럼 설정 시 "적용할 메뉴 선택" 기능
|
||||||
|
2. 2레벨 메뉴 목록 조회 및 다중 선택 UI
|
||||||
|
3. 선택된 메뉴별로 카테고리 값 생성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CategoryMenuScope {
|
||||||
|
menuObjid: number;
|
||||||
|
menuName: string;
|
||||||
|
parentMenuName: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryColumnConfig({ tableName, columnName }: Props) {
|
||||||
|
const [menuScopes, setMenuScopes] = useState<CategoryMenuScope[]>([]);
|
||||||
|
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// 2레벨 메뉴 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadMenuScopes() {
|
||||||
|
const response = await apiClient.get("/api/menus/second-level");
|
||||||
|
if (response.data.success) {
|
||||||
|
setMenuScopes(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadMenuScopes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 값 저장 시 선택된 메뉴들에 대해 각각 저장
|
||||||
|
const handleSaveCategoryValue = async (categoryData: any) => {
|
||||||
|
for (const menuObjid of selectedMenus) {
|
||||||
|
await apiClient.post("/api/categories/values", {
|
||||||
|
...categoryData,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
menuObjid, // 메뉴별로 저장
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("카테고리가 선택된 메뉴에 저장되었습니다");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>적용할 메뉴 선택</Label>
|
||||||
|
<div className="border rounded-lg p-4 space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{menuScopes.map((scope) => (
|
||||||
|
<div key={scope.menuObjid} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedMenus.includes(scope.menuObjid)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedMenus([...selectedMenus, scope.menuObjid]);
|
||||||
|
} else {
|
||||||
|
setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
{scope.parentMenuName} → {scope.menuName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMenus.length === 0 && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
최소 하나 이상의 메뉴를 선택해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 값 추가 UI */}
|
||||||
|
<CategoryValueEditor
|
||||||
|
onSave={handleSaveCategoryValue}
|
||||||
|
disabled={selectedMenus.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 2레벨 메뉴 조회 API 추가
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/menuController.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 2레벨 메뉴 목록 조회
|
||||||
|
* (카테고리 스코프 선택용)
|
||||||
|
*/
|
||||||
|
export async function getSecondLevelMenus(req: Request, res: Response) {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m2.objid as menu_objid,
|
||||||
|
m2.menu_name_kor as menu_name,
|
||||||
|
m1.menu_name_kor as parent_menu_name,
|
||||||
|
m2.screen_code
|
||||||
|
FROM menu_info m2
|
||||||
|
JOIN menu_info m1 ON m2.parent_obj_id = m1.objid
|
||||||
|
WHERE m2.menu_type = 2 -- 2레벨 메뉴
|
||||||
|
AND (m2.company_code = $1 OR m2.company_code = '*')
|
||||||
|
AND m2.status = 'Y'
|
||||||
|
ORDER BY m1.seq, m2.seq
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [companyCode]);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**라우트 등록**:
|
||||||
|
```typescript
|
||||||
|
router.get("/api/menus/second-level", authenticate, getSecondLevelMenus);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선
|
||||||
|
|
||||||
|
#### 3.1 카테고리 컴포넌트 조회 로직 변경
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/registry/components/category/CategoryComponent.tsx`
|
||||||
|
|
||||||
|
**현재 로직** (형제 메뉴 기반):
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCategories() {
|
||||||
|
// 형제 메뉴들의 테이블에서 카테고리 조회
|
||||||
|
const response = await apiClient.get(`/api/categories/${tableName}`);
|
||||||
|
}
|
||||||
|
loadCategories();
|
||||||
|
}, [tableName]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선된 로직** (2레벨 메뉴 기반):
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCategories() {
|
||||||
|
// 현재 메뉴 OBJID 가져오기
|
||||||
|
const menuObjid = screenConfig.menuObjid; // 또는 URL에서 파싱
|
||||||
|
|
||||||
|
if (!menuObjid) {
|
||||||
|
console.warn("메뉴 OBJID를 찾을 수 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2레벨 메뉴 기준으로 카테고리 조회
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/categories/${tableName}/menu/${menuObjid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
setCategories(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadCategories();
|
||||||
|
}, [tableName, screenConfig.menuObjid]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 API 클라이언트 함수 추가
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/api/category.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 특정 메뉴 스코프의 카테고리 컬럼 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
*/
|
||||||
|
export async function getCategoriesByMenuScope(
|
||||||
|
tableName: string,
|
||||||
|
menuObjid: number
|
||||||
|
): Promise<ApiResponse<CategoryColumn[]>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/categories/${tableName}/menu/${menuObjid}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 마이그레이션
|
||||||
|
|
||||||
|
### 4.1 기존 카테고리 값에 menu_objid 설정
|
||||||
|
|
||||||
|
**문제**: 기존 카테고리 값들은 `menu_objid`가 설정되지 않음
|
||||||
|
|
||||||
|
**해결**: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도
|
||||||
|
|
||||||
|
#### 마이그레이션 스크립트 (선택사항)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- db/migrations/053_backfill_category_menu_objid.sql
|
||||||
|
|
||||||
|
-- Step 1: 기존 카테고리 값 확인
|
||||||
|
SELECT
|
||||||
|
value_id,
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
value_label,
|
||||||
|
menu_objid,
|
||||||
|
company_code
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE menu_objid IS NULL OR menu_objid = 0;
|
||||||
|
|
||||||
|
-- Step 2: 기본값 설정 (예시)
|
||||||
|
-- 관리자가 수동으로 올바른 menu_objid를 설정해야 함
|
||||||
|
UPDATE table_column_category_values
|
||||||
|
SET menu_objid = 103 -- 예: 기준정보 > 품목정보
|
||||||
|
WHERE table_name = 'item_info'
|
||||||
|
AND column_name = 'category_type'
|
||||||
|
AND value_code IN ('STOCK_ITEM', 'ASSET')
|
||||||
|
AND (menu_objid IS NULL OR menu_objid = 0);
|
||||||
|
|
||||||
|
UPDATE table_column_category_values
|
||||||
|
SET menu_objid = 203 -- 예: 영업관리 > 판매품목정보
|
||||||
|
WHERE table_name = 'item_info'
|
||||||
|
AND column_name = 'category_type'
|
||||||
|
AND value_code IN ('SALES_ITEM', 'SERVICE')
|
||||||
|
AND (menu_objid IS NULL OR menu_objid = 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 UI에서 menu_objid 미설정 경고
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TableTypeManagement.tsx
|
||||||
|
function CategoryColumnList({ categoryColumns }: Props) {
|
||||||
|
const unassignedCategories = categoryColumns.filter(
|
||||||
|
(col) => !col.menuObjid || col.menuObjid === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unassignedCategories.length > 0) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>메뉴 스코프 미설정</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{unassignedCategories.length}개의 카테고리 값에 메뉴가 설정되지 않았습니다.
|
||||||
|
<br />
|
||||||
|
각 카테고리가 어떤 메뉴에서 사용될지 설정해주세요.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 시나리오
|
||||||
|
|
||||||
|
### 5.1 기본 기능 테스트
|
||||||
|
|
||||||
|
1. **2레벨 메뉴 조회**
|
||||||
|
- `GET /api/menus/second-level`
|
||||||
|
- 기준정보, 영업관리 등 2레벨 메뉴들이 조회되는지 확인
|
||||||
|
|
||||||
|
2. **카테고리 값 생성 (메뉴 스코프 포함)**
|
||||||
|
- 테이블 타입 관리에서 카테고리 컬럼 선택
|
||||||
|
- 적용할 메뉴 선택 (예: 영업관리 > 판매품목정보)
|
||||||
|
- 카테고리 값 추가
|
||||||
|
- DB에 올바른 `menu_objid`로 저장되는지 확인
|
||||||
|
|
||||||
|
3. **카테고리 컴포넌트 조회 (메뉴별 필터링)**
|
||||||
|
- `기준정보 > 품목정보` 화면 접속
|
||||||
|
- 해당 메뉴의 카테고리만 표시되는지 확인
|
||||||
|
- `영업관리 > 판매품목정보` 화면 접속
|
||||||
|
- 다른 카테고리가 표시되는지 확인
|
||||||
|
|
||||||
|
### 5.2 엣지 케이스 테스트
|
||||||
|
|
||||||
|
1. **menu_objid 미설정 카테고리**
|
||||||
|
- 기존 카테고리 값 (menu_objid = NULL)
|
||||||
|
- 경고 메시지 표시
|
||||||
|
- 조회 시 제외되는지 확인
|
||||||
|
|
||||||
|
2. **여러 메뉴에 동일 카테고리**
|
||||||
|
- 하나의 카테고리를 여러 메뉴에 적용
|
||||||
|
- 각 메뉴에서 독립적으로 관리되는지 확인
|
||||||
|
|
||||||
|
3. **최고 관리자 권한**
|
||||||
|
- 최고 관리자는 모든 메뉴의 카테고리를 볼 수 있는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 롤백 계획
|
||||||
|
|
||||||
|
만약 문제가 발생하면 다음 단계로 롤백:
|
||||||
|
|
||||||
|
### 6.1 백엔드 API 롤백
|
||||||
|
```bash
|
||||||
|
git revert <commit-hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 데이터베이스 롤백
|
||||||
|
```sql
|
||||||
|
-- menu_objid 조건 제거 (임시)
|
||||||
|
-- 기존 로직으로 돌아감
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 프론트엔드 롤백
|
||||||
|
- 카테고리 조회 시 `menuObjid` 파라미터 제거
|
||||||
|
- 형제 메뉴 기반 로직으로 복원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1 (필수)
|
||||||
|
1. ✅ 백엔드: `getTopLevelMenuId()` 함수 구현
|
||||||
|
2. ✅ 백엔드: 카테고리 조회 API에 `menu_objid` 필터링 추가
|
||||||
|
3. ✅ 백엔드: 카테고리 값 생성 시 `menu_objid` 필수 처리
|
||||||
|
4. ✅ 백엔드: 2레벨 메뉴 목록 조회 API 추가
|
||||||
|
|
||||||
|
### Phase 2 (필수)
|
||||||
|
5. ✅ 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가
|
||||||
|
6. ✅ 프론트엔드: 카테고리 컴포넌트 조회 로직 변경
|
||||||
|
|
||||||
|
### Phase 3 (권장)
|
||||||
|
7. ⏳ 데이터 마이그레이션: 기존 카테고리 값에 `menu_objid` 설정
|
||||||
|
8. ⏳ UI: `menu_objid` 미설정 경고 표시
|
||||||
|
9. ⏳ 테스트: 시나리오별 검증
|
||||||
|
|
||||||
|
### Phase 4 (선택)
|
||||||
|
10. ⏳ 관리자 대시보드: 카테고리 사용 현황 통계
|
||||||
|
11. ⏳ 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 기대 효과
|
||||||
|
|
||||||
|
### 8.1 문제 해결
|
||||||
|
- ✅ 같은 테이블을 사용하는 메뉴들도 서로 다른 카테고리 사용 가능
|
||||||
|
- ✅ 메뉴별 카테고리 독립성 보장
|
||||||
|
- ✅ 관리자가 메뉴별로 카테고리를 명확히 제어
|
||||||
|
|
||||||
|
### 8.2 사용자 경험 개선
|
||||||
|
- ✅ 카테고리가 메뉴 문맥에 맞게 표시됨
|
||||||
|
- ✅ 불필요한 카테고리가 표시되지 않음
|
||||||
|
- ✅ 직관적인 카테고리 관리 UI
|
||||||
|
|
||||||
|
### 8.3 확장성
|
||||||
|
- ✅ 향후 메뉴별 세밀한 권한 제어 가능
|
||||||
|
- ✅ 메뉴별 카테고리 통계 및 분석 가능
|
||||||
|
- ✅ 다른 컴포넌트에도 유사한 메뉴 스코프 패턴 적용 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 참고 자료
|
||||||
|
|
||||||
|
- 데이터베이스 스키마: `table_column_category_values` 테이블
|
||||||
|
- 메뉴 구조: `menu_info` 테이블
|
||||||
|
- 카테고리 컴포넌트: `frontend/lib/registry/components/category/CategoryComponent.tsx`
|
||||||
|
- 테이블 타입 관리: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
|
||||||
|
- API 클라이언트: `frontend/lib/api/category.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 결론
|
||||||
|
|
||||||
|
사용자가 제안한 **"테이블 타입 관리에서 카테고리 컬럼 설정 시 2레벨 메뉴 선택"** 방식이 가장 현실적이고 효과적인 해결책입니다.
|
||||||
|
|
||||||
|
**핵심 변경사항**:
|
||||||
|
1. 카테고리 값 저장 시 `menu_objid` 필수 입력
|
||||||
|
2. 카테고리 조회 시 2레벨 메뉴 기준 필터링 (형제 메뉴 기준 제거)
|
||||||
|
3. 테이블 타입 관리 UI에서 메뉴 선택 기능 추가
|
||||||
|
|
||||||
|
이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.
|
||||||
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
import { ddlApi } from "@/lib/api/ddl";
|
import { ddlApi } from "@/lib/api/ddl";
|
||||||
|
import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue";
|
||||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||||
|
|
@ -54,6 +55,14 @@ interface ColumnTypeInfo {
|
||||||
referenceTable?: string;
|
referenceTable?: string;
|
||||||
referenceColumn?: string;
|
referenceColumn?: string;
|
||||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
|
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecondLevelMenu {
|
||||||
|
menuObjid: number;
|
||||||
|
menuName: string;
|
||||||
|
parentMenuName: string;
|
||||||
|
screenCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TableManagementPage() {
|
export default function TableManagementPage() {
|
||||||
|
|
@ -89,6 +98,9 @@ export default function TableManagementPage() {
|
||||||
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
|
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
|
||||||
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
|
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||||
|
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
||||||
|
|
||||||
// 로그 뷰어 상태
|
// 로그 뷰어 상태
|
||||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||||
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
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 () => {
|
const loadTables = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -257,10 +285,17 @@ export default function TableManagementPage() {
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const data = response.data.data;
|
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) => ({
|
const processedColumns = (data.columns || data).map((col: any) => ({
|
||||||
...col,
|
...col,
|
||||||
inputType: col.inputType || "text", // 기본값: text
|
inputType: col.inputType || "text", // 기본값: text
|
||||||
|
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
|
|
@ -438,12 +473,72 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
// 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`, [
|
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
||||||
columnSetting,
|
columnSetting,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.data.success) {
|
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)));
|
setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col)));
|
||||||
|
|
||||||
|
|
@ -501,14 +596,78 @@ export default function TableManagementPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success) {
|
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]);
|
setOriginalColumns([...columns]);
|
||||||
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
|
|
||||||
|
|
||||||
// 테이블 목록 새로고침 (라벨 변경 반영)
|
// 테이블 목록 새로고침 (라벨 변경 반영)
|
||||||
loadTables();
|
loadTables();
|
||||||
|
|
||||||
// 저장 후 데이터 확인을 위해 다시 로드
|
// 저장 후 데이터 다시 로드
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadColumnTypes(selectedTable, 1, pageSize);
|
loadColumnTypes(selectedTable, 1, pageSize);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
@ -539,6 +698,7 @@ export default function TableManagementPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTables();
|
loadTables();
|
||||||
loadCommonCodeCategories();
|
loadCommonCodeCategories();
|
||||||
|
loadSecondLevelMenus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||||
|
|
@ -1023,10 +1183,61 @@ export default function TableManagementPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
{/* 입력 타입이 'category'인 경우 안내 메시지 */}
|
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||||
{column.inputType === "category" && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: screenUrl,
|
menuUrl: screenUrl,
|
||||||
|
screenCode: screen.screenCode, // 화면 코드도 함께 저장
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// console.log("🖥️ 화면 선택 완료:", {
|
// console.log("🖥️ 화면 선택 완료:", {
|
||||||
|
|
@ -207,10 +208,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
if (type === "direct") {
|
if (type === "direct") {
|
||||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
// URL 필드도 초기화 (사용자가 직접 입력할 수 있도록)
|
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
|
screenCode: undefined, // 화면 코드도 함께 초기화
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// 화면 할당 모드로 변경 시
|
// 화면 할당 모드로 변경 시
|
||||||
|
|
@ -230,12 +232,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: screenUrl,
|
menuUrl: screenUrl,
|
||||||
|
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// 선택된 화면이 없으면 URL만 초기화
|
// 선택된 화면이 없으면 URL과 screenCode 초기화
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
menuUrl: "",
|
menuUrl: "",
|
||||||
|
screenCode: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, ChevronRight, ChevronDown } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Search, ChevronRight, ChevronDown, ChevronsDown, ChevronsUp, BookOpen, Shield, Eye, CheckSquare, Building2 } from "lucide-react";
|
||||||
import { RoleGroup, roleAPI } from "@/lib/api/role";
|
import { RoleGroup, roleAPI } from "@/lib/api/role";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface MenuPermission {
|
interface MenuPermission {
|
||||||
menuObjid: number;
|
menuObjid: number;
|
||||||
menuName: string;
|
menuName: string;
|
||||||
menuPath?: string;
|
menuPath?: string;
|
||||||
parentObjid?: number;
|
parentObjid?: number;
|
||||||
|
companyCode?: string;
|
||||||
createYn: string;
|
createYn: string;
|
||||||
readYn: string;
|
readYn: string;
|
||||||
updateYn: string;
|
updateYn: string;
|
||||||
|
|
@ -41,10 +45,37 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
|
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
|
||||||
const [allMenus, setAllMenus] = useState<any[]>([]);
|
const [allMenus, setAllMenus] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 최고 관리자 전용: 회사별 필터
|
||||||
|
const [companyFilter, setCompanyFilter] = useState<string>("all"); // 초기값: 모든 메뉴
|
||||||
|
const [companyInfo, setCompanyInfo] = useState<{ code: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
// 메뉴 타입 필터 (관리자/사용자)
|
||||||
|
const [menuTypeFilter, setMenuTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
// 최고 관리자 여부 확인
|
// 최고 관리자 여부 확인
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 회사 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCompanyInfo = async () => {
|
||||||
|
if (roleGroup.companyCode && roleGroup.companyCode !== "*") {
|
||||||
|
try {
|
||||||
|
const { companyAPI } = await import("@/lib/api/company");
|
||||||
|
const company = await companyAPI.getInfo(roleGroup.companyCode);
|
||||||
|
setCompanyInfo({
|
||||||
|
code: company.companyCode,
|
||||||
|
name: company.companyName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 정보 로드 실패", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCompanyInfo();
|
||||||
|
}, [roleGroup.companyCode]);
|
||||||
|
|
||||||
// 전체 메뉴 목록 로드
|
// 전체 메뉴 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// currentUser가 로드될 때까지 대기
|
// currentUser가 로드될 때까지 대기
|
||||||
|
|
@ -54,9 +85,15 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAllMenus = async () => {
|
const loadAllMenus = async () => {
|
||||||
// 최고 관리자: companyCode 없이 모든 메뉴 조회
|
let targetCompanyCode: string | undefined;
|
||||||
// 회사 관리자: 자기 회사 메뉴만 조회
|
|
||||||
const targetCompanyCode = isSuperAdmin ? undefined : roleGroup.companyCode;
|
if (isSuperAdmin) {
|
||||||
|
// 최고 관리자: 권한그룹의 회사 코드로 조회 (해당 회사 + 공통 메뉴 모두 반환)
|
||||||
|
targetCompanyCode = roleGroup.companyCode;
|
||||||
|
} else {
|
||||||
|
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||||
|
targetCompanyCode = roleGroup.companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
|
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
|
||||||
currentUser: {
|
currentUser: {
|
||||||
|
|
@ -66,7 +103,8 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
},
|
},
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
roleGroupCompanyCode: roleGroup.companyCode,
|
roleGroupCompanyCode: roleGroup.companyCode,
|
||||||
targetCompanyCode: targetCompanyCode || "전체",
|
companyFilter,
|
||||||
|
targetCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -81,6 +119,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setAllMenus(response.data);
|
setAllMenus(response.data);
|
||||||
|
|
||||||
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
|
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
|
||||||
count: response.data.length,
|
count: response.data.length,
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +132,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAllMenus();
|
loadAllMenus();
|
||||||
}, [currentUser, isSuperAdmin, roleGroup.companyCode]);
|
}, [currentUser, isSuperAdmin, roleGroup.companyCode, companyFilter]);
|
||||||
|
|
||||||
// 메뉴 권한 상태 (로컬 상태 관리)
|
// 메뉴 권한 상태 (로컬 상태 관리)
|
||||||
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
|
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
|
||||||
|
|
@ -108,11 +147,16 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
// 기존 권한이 있으면 사용, 없으면 기본값
|
// 기존 권한이 있으면 사용, 없으면 기본값
|
||||||
const existingPermission = permissions.find((p) => p.menuObjid === menu.objid);
|
const existingPermission = permissions.find((p) => p.menuObjid === menu.objid);
|
||||||
|
|
||||||
permissionsMap.set(menu.objid, {
|
// objid를 숫자로 변환하여 저장
|
||||||
menuObjid: menu.objid,
|
const menuObjid = Number(menu.objid);
|
||||||
|
const parentObjid = menu.parentObjid ? Number(menu.parentObjid) : 0;
|
||||||
|
|
||||||
|
permissionsMap.set(menuObjid, {
|
||||||
|
menuObjid,
|
||||||
menuName: menu.menuName,
|
menuName: menu.menuName,
|
||||||
menuPath: menu.menuUrl,
|
menuPath: menu.menuUrl,
|
||||||
parentObjid: menu.parentObjid,
|
parentObjid,
|
||||||
|
companyCode: menu.companyCode,
|
||||||
createYn: existingPermission?.createYn || "N",
|
createYn: existingPermission?.createYn || "N",
|
||||||
readYn: existingPermission?.readYn || "N",
|
readYn: existingPermission?.readYn || "N",
|
||||||
updateYn: existingPermission?.updateYn || "N",
|
updateYn: existingPermission?.updateYn || "N",
|
||||||
|
|
@ -136,8 +180,159 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
}
|
}
|
||||||
}, [menuPermissions, isInitialized, onPermissionsChange]);
|
}, [menuPermissions, isInitialized, onPermissionsChange]);
|
||||||
|
|
||||||
|
// 메뉴 트리 구조 생성 및 필터링
|
||||||
|
const buildMenuTree = useCallback((menus: MenuPermission[]): MenuPermission[] => {
|
||||||
|
const menuMap = new Map<number, MenuPermission>();
|
||||||
|
const rootMenus: MenuPermission[] = [];
|
||||||
|
|
||||||
|
// 먼저 모든 메뉴를 Map에 저장
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
menuMap.set(menu.menuObjid, { ...menu, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부모-자식 관계 구성
|
||||||
|
menuMap.forEach((menu) => {
|
||||||
|
// parentObjid를 숫자로 변환하여 비교
|
||||||
|
const parentId = Number(menu.parentObjid);
|
||||||
|
|
||||||
|
if (!menu.parentObjid || parentId === 0 || isNaN(parentId)) {
|
||||||
|
rootMenus.push(menu);
|
||||||
|
} else {
|
||||||
|
const parent = menuMap.get(parentId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children = parent.children || [];
|
||||||
|
parent.children.push(menu);
|
||||||
|
} else {
|
||||||
|
// 부모를 찾을 수 없으면 최상위로 처리
|
||||||
|
console.warn("⚠️ 부모 메뉴를 찾을 수 없음", {
|
||||||
|
menuObjid: menu.menuObjid,
|
||||||
|
menuName: menu.menuName,
|
||||||
|
parentObjid: menu.parentObjid,
|
||||||
|
parentId,
|
||||||
|
});
|
||||||
|
rootMenus.push(menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootMenus;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
const filterMenus = useCallback(
|
||||||
|
(menus: MenuPermission[], searchText: string): MenuPermission[] => {
|
||||||
|
if (!searchText.trim()) {
|
||||||
|
return menus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = searchText.toLowerCase();
|
||||||
|
const filtered: MenuPermission[] = [];
|
||||||
|
|
||||||
|
const matchesSearch = (menu: MenuPermission): boolean => {
|
||||||
|
return menu.menuName.toLowerCase().includes(search);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRecursive = (menu: MenuPermission): MenuPermission | null => {
|
||||||
|
const matches = matchesSearch(menu);
|
||||||
|
const filteredChildren = (menu.children || [])
|
||||||
|
.map((child) => filterRecursive(child))
|
||||||
|
.filter((child): child is MenuPermission => child !== null);
|
||||||
|
|
||||||
|
if (matches || filteredChildren.length > 0) {
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
children: filteredChildren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
const result = filterRecursive(menu);
|
||||||
|
if (result) {
|
||||||
|
filtered.push(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 메뉴 트리 구조 생성 (menuPermissions에서)
|
// 메뉴 트리 구조 생성 (menuPermissions에서)
|
||||||
const menuTree: MenuPermission[] = Array.from(menuPermissions.values());
|
const menuTree = useMemo(() => {
|
||||||
|
let allMenusArray = Array.from(menuPermissions.values());
|
||||||
|
|
||||||
|
// 회사 필터링 (최고 관리자 전용)
|
||||||
|
if (isSuperAdmin && companyFilter !== "all") {
|
||||||
|
// 특정 회사 또는 공통 메뉴만 선택한 경우
|
||||||
|
allMenusArray = allMenusArray.filter(menu => menu.companyCode === companyFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메뉴 타입 필터링 (관리자/사용자)
|
||||||
|
if (menuTypeFilter !== "all") {
|
||||||
|
const targetMenuType = menuTypeFilter; // "0" (관리자) 또는 "1" (사용자)
|
||||||
|
allMenusArray = allMenusArray.filter(menu => {
|
||||||
|
// 백엔드에서 받은 menuType을 비교 (allMenus에서 가져와야 함)
|
||||||
|
const originalMenu = allMenus.find(m => Number(m.objid) === menu.menuObjid);
|
||||||
|
return originalMenu && String(originalMenu.menuType) === targetMenuType;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🌲 [MenuTree] 트리 생성 시작", {
|
||||||
|
allMenusCount: allMenusArray.length,
|
||||||
|
searchText,
|
||||||
|
menuTypeFilter,
|
||||||
|
sampleMenu: allMenusArray[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = buildMenuTree(allMenusArray);
|
||||||
|
console.log("🌲 [MenuTree] 빌드 완료", {
|
||||||
|
rootMenusCount: tree.length,
|
||||||
|
rootMenus: tree.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName, parentObjid: m.parentObjid })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = filterMenus(tree, searchText);
|
||||||
|
console.log("🌲 [MenuTree] 필터링 완료", {
|
||||||
|
filteredCount: filtered.length,
|
||||||
|
filtered: filtered.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName })),
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [menuPermissions, searchText, menuTypeFilter, allMenus, isSuperAdmin, companyFilter, buildMenuTree, filterMenus]);
|
||||||
|
|
||||||
|
// 통계 계산
|
||||||
|
const statistics = useMemo(() => {
|
||||||
|
let totalMenus = 0;
|
||||||
|
let menusWithPermissions = 0;
|
||||||
|
|
||||||
|
menuPermissions.forEach((menu) => {
|
||||||
|
totalMenus++;
|
||||||
|
if (menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y") {
|
||||||
|
menusWithPermissions++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalMenus, menusWithPermissions };
|
||||||
|
}, [menuPermissions]);
|
||||||
|
|
||||||
|
// 전체 펼치기/접기
|
||||||
|
const expandAll = useCallback(() => {
|
||||||
|
const allIds = new Set<number>();
|
||||||
|
const collectIds = (menu: MenuPermission) => {
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
allIds.add(menu.menuObjid);
|
||||||
|
menu.children.forEach(collectIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
menuTree.forEach(collectIds);
|
||||||
|
setExpandedMenus(allIds);
|
||||||
|
}, [menuTree]);
|
||||||
|
|
||||||
|
const collapseAll = useCallback(() => {
|
||||||
|
setExpandedMenus(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 메뉴 펼치기/접기 토글
|
// 메뉴 펼치기/접기 토글
|
||||||
const toggleExpand = useCallback((menuObjid: number) => {
|
const toggleExpand = useCallback((menuObjid: number) => {
|
||||||
|
|
@ -152,26 +347,52 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 권한 변경 핸들러
|
// 하위 메뉴 ID 수집
|
||||||
|
const collectChildIds = useCallback((menu: MenuPermission): number[] => {
|
||||||
|
const ids = [menu.menuObjid];
|
||||||
|
if (menu.children) {
|
||||||
|
menu.children.forEach((child) => {
|
||||||
|
ids.push(...collectChildIds(child));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 변경 핸들러 (하위 메뉴 포함)
|
||||||
const handlePermissionChange = useCallback(
|
const handlePermissionChange = useCallback(
|
||||||
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => {
|
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean, applyToChildren = true) => {
|
||||||
setMenuPermissions((prev) => {
|
setMenuPermissions((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const menuPerm = newMap.get(menuObjid);
|
const menuPerm = newMap.get(menuObjid);
|
||||||
|
|
||||||
if (menuPerm) {
|
if (!menuPerm) return newMap;
|
||||||
newMap.set(menuObjid, {
|
|
||||||
...menuPerm,
|
// 현재 메뉴 권한 변경
|
||||||
[permission]: checked ? "Y" : "N",
|
newMap.set(menuObjid, {
|
||||||
|
...menuPerm,
|
||||||
|
[permission]: checked ? "Y" : "N",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 하위 메뉴에도 적용 (옵션)
|
||||||
|
if (applyToChildren && menuPerm.children && menuPerm.children.length > 0) {
|
||||||
|
const childIds = collectChildIds(menuPerm);
|
||||||
|
childIds.forEach((childId) => {
|
||||||
|
const childPerm = newMap.get(childId);
|
||||||
|
if (childPerm) {
|
||||||
|
newMap.set(childId, {
|
||||||
|
...childPerm,
|
||||||
|
[permission]: checked ? "Y" : "N",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 권한 변경:", { menuObjid, permission, checked });
|
console.log("✅ 권한 변경:", { menuObjid, permission, checked, applyToChildren });
|
||||||
},
|
},
|
||||||
[],
|
[collectChildIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 전체 선택/해제
|
// 전체 선택/해제
|
||||||
|
|
@ -195,29 +416,142 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 필터링된 메뉴 ID 수집 (재귀)
|
||||||
|
const collectFilteredMenuIds = useCallback((menus: MenuPermission[]): Set<number> => {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
|
||||||
|
const traverse = (menu: MenuPermission) => {
|
||||||
|
ids.add(menu.menuObjid);
|
||||||
|
if (menu.children) {
|
||||||
|
menu.children.forEach(traverse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
menus.forEach(traverse);
|
||||||
|
return ids;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 빠른 권한 설정 프리셋 (필터링된 메뉴만)
|
||||||
|
const applyPreset = useCallback((preset: "read-only" | "full" | "none") => {
|
||||||
|
// 현재 필터링된 메뉴 ID 수집
|
||||||
|
const filteredIds = collectFilteredMenuIds(menuTree);
|
||||||
|
|
||||||
|
console.log("🎯 프리셋 적용 대상:", {
|
||||||
|
preset,
|
||||||
|
filteredCount: filteredIds.size,
|
||||||
|
filteredIds: Array.from(filteredIds),
|
||||||
|
});
|
||||||
|
|
||||||
|
setMenuPermissions((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
|
||||||
|
// 필터링된 메뉴만 권한 변경
|
||||||
|
filteredIds.forEach((menuObjid) => {
|
||||||
|
const menuPerm = newMap.get(menuObjid);
|
||||||
|
if (!menuPerm) return;
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "read-only":
|
||||||
|
newMap.set(menuObjid, {
|
||||||
|
...menuPerm,
|
||||||
|
createYn: "N",
|
||||||
|
readYn: "Y",
|
||||||
|
updateYn: "N",
|
||||||
|
deleteYn: "N",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "full":
|
||||||
|
newMap.set(menuObjid, {
|
||||||
|
...menuPerm,
|
||||||
|
createYn: "Y",
|
||||||
|
readYn: "Y",
|
||||||
|
updateYn: "Y",
|
||||||
|
deleteYn: "Y",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "none":
|
||||||
|
newMap.set(menuObjid, {
|
||||||
|
...menuPerm,
|
||||||
|
createYn: "N",
|
||||||
|
readYn: "N",
|
||||||
|
updateYn: "N",
|
||||||
|
deleteYn: "N",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 프리셋 적용 완료:", { preset, count: filteredIds.size });
|
||||||
|
}, [menuTree, collectFilteredMenuIds]);
|
||||||
|
|
||||||
|
// 회사 코드에서 회사명 가져오기
|
||||||
|
const getCompanyLabel = useCallback((code: string) => {
|
||||||
|
if (code === "*") {
|
||||||
|
return "공통";
|
||||||
|
}
|
||||||
|
// 현재 권한그룹의 회사 코드와 일치하면 회사명 표시
|
||||||
|
if (companyInfo && code === companyInfo.code) {
|
||||||
|
return companyInfo.name;
|
||||||
|
}
|
||||||
|
// 그 외에는 회사 코드 표시
|
||||||
|
return code;
|
||||||
|
}, [companyInfo]);
|
||||||
|
|
||||||
// 메뉴 행 렌더링
|
// 메뉴 행 렌더링
|
||||||
const renderMenuRow = (menu: MenuPermission, level: number = 0) => {
|
const renderMenuRow = (menu: MenuPermission, level: number = 0) => {
|
||||||
const hasChildren = menu.children && menu.children.length > 0;
|
const hasChildren = menu.children && menu.children.length > 0;
|
||||||
const isExpanded = expandedMenus.has(menu.menuObjid);
|
const isExpanded = expandedMenus.has(menu.menuObjid);
|
||||||
const paddingLeft = level * 24;
|
const paddingLeft = level * 24;
|
||||||
|
const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={menu.menuObjid}>
|
<React.Fragment key={menu.menuObjid}>
|
||||||
<TableRow className="hover:bg-muted/50 transition-colors">
|
<TableRow
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 transition-colors",
|
||||||
|
hasAnyPermission && "bg-primary/5 border-l-2 border-l-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* 메뉴명 */}
|
{/* 메뉴명 */}
|
||||||
<TableCell className="h-16 text-sm" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
|
<TableCell className="h-12 text-sm py-2" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<button onClick={() => toggleExpand(menu.menuObjid)} className="transition-transform">
|
<button
|
||||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
onClick={() => toggleExpand(menu.menuObjid)}
|
||||||
|
className="p-1 hover:bg-muted rounded transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className={`text-sm ${hasChildren ? "font-semibold" : "font-medium"}`}>{menu.menuName}</span>
|
{!hasChildren && <div className="w-6" />}
|
||||||
|
<span className={cn("text-sm", hasChildren ? "font-semibold" : "font-medium", hasAnyPermission && "text-primary")}>
|
||||||
|
{menu.menuName}
|
||||||
|
</span>
|
||||||
|
{menu.companyCode && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 text-[10px] font-medium rounded",
|
||||||
|
menu.companyCode === "*"
|
||||||
|
? "bg-primary/10 text-primary border border-primary/20"
|
||||||
|
: "bg-muted text-muted-foreground border border-border"
|
||||||
|
)}
|
||||||
|
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
|
||||||
|
>
|
||||||
|
{getCompanyLabel(menu.companyCode)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 생성(Create) */}
|
{/* 생성(Create) */}
|
||||||
<TableCell className="h-16 text-center text-sm">
|
<TableCell className="h-12 text-center text-sm py-2">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.createYn === "Y"}
|
checked={menu.createYn === "Y"}
|
||||||
|
|
@ -227,7 +561,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 조회(Read) */}
|
{/* 조회(Read) */}
|
||||||
<TableCell className="h-16 text-center text-sm">
|
<TableCell className="h-12 text-center text-sm py-2">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.readYn === "Y"}
|
checked={menu.readYn === "Y"}
|
||||||
|
|
@ -237,7 +571,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 수정(Update) */}
|
{/* 수정(Update) */}
|
||||||
<TableCell className="h-16 text-center text-sm">
|
<TableCell className="h-12 text-center text-sm py-2">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.updateYn === "Y"}
|
checked={menu.updateYn === "Y"}
|
||||||
|
|
@ -247,7 +581,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 삭제(Delete) */}
|
{/* 삭제(Delete) */}
|
||||||
<TableCell className="h-16 text-center text-sm">
|
<TableCell className="h-12 text-center text-sm py-2">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.deleteYn === "Y"}
|
checked={menu.deleteYn === "Y"}
|
||||||
|
|
@ -265,105 +599,274 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 검색 */}
|
{/* 필터 영역 (통합) */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{/* 왼쪽: 필터들 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
{/* 최고 관리자 전용: 회사 필터 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">회사:</span>
|
||||||
|
<Select value={companyFilter} onValueChange={setCompanyFilter}>
|
||||||
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||||
|
<SelectValue placeholder="회사 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* 전체 메뉴 */}
|
||||||
|
<SelectItem value="all">전체 메뉴</SelectItem>
|
||||||
|
|
||||||
|
{/* 권한그룹의 회사 */}
|
||||||
|
{roleGroup.companyCode && roleGroup.companyCode !== "*" && companyInfo && (
|
||||||
|
<SelectItem value={roleGroup.companyCode}>
|
||||||
|
{companyInfo.name}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공통 메뉴 */}
|
||||||
|
<SelectItem value="*">공통 메뉴</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="hidden h-6 w-px bg-border sm:block"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메뉴 타입 필터 (모든 사용자) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">타입:</span>
|
||||||
|
<Select value={menuTypeFilter} onValueChange={setMenuTypeFilter}>
|
||||||
|
<SelectTrigger className="h-8 w-[160px] text-xs">
|
||||||
|
<SelectValue placeholder="메뉴 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 메뉴</SelectItem>
|
||||||
|
<SelectItem value="0">관리자</SelectItem>
|
||||||
|
<SelectItem value="1">사용자</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 메뉴 개수 */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
({statistics.totalMenus}개 메뉴)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 및 빠른 액션 */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
전체: <span className="font-semibold text-foreground">{statistics.totalMenus}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
권한 있음: <span className="font-semibold text-primary">{statistics.menusWithPermissions}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => applyPreset("read-only")} className="gap-1.5">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
조회만
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => applyPreset("full")} className="gap-1.5">
|
||||||
|
<CheckSquare className="h-3.5 w-3.5" />
|
||||||
|
전체 권한
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => applyPreset("none")} className="gap-1.5">
|
||||||
|
전체 해제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 트리 제어 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative flex-1 sm:max-w-[400px]">
|
<div className="relative flex-1 sm:max-w-[400px]">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="메뉴 검색..."
|
placeholder="메뉴 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-9 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={expandAll} className="gap-1.5">
|
||||||
|
<ChevronsDown className="h-3.5 w-3.5" />
|
||||||
|
전체 펼치기
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={collapseAll} className="gap-1.5">
|
||||||
|
<ChevronsUp className="h-3.5 w-3.5" />
|
||||||
|
전체 접기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
{searchText && menuTree.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
"{searchText}"에 대한 검색 결과가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searchText && menuTree.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<BookOpen className="h-12 w-12 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
메뉴 데이터가 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데스크톱 테이블 */}
|
{/* 데스크톱 테이블 */}
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
{menuTree.length > 0 && (
|
||||||
<Table>
|
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead className="h-12 w-[40%] text-sm font-semibold">메뉴</TableHead>
|
<TableRow className="bg-muted/30">
|
||||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
<TableHead className="h-11 w-[40%] text-xs font-semibold">메뉴</TableHead>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||||
<span>생성 (C)</span>
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<Checkbox
|
<span>생성 (C)</span>
|
||||||
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
<Checkbox
|
||||||
className="mt-1"
|
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
||||||
/>
|
className="data-[state=checked]:bg-green-600"
|
||||||
</div>
|
/>
|
||||||
</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
</TableHead>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||||
<span>조회 (R)</span>
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<Checkbox
|
<span>조회 (R)</span>
|
||||||
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
<Checkbox
|
||||||
className="mt-1"
|
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
||||||
/>
|
className="data-[state=checked]:bg-blue-600"
|
||||||
</div>
|
/>
|
||||||
</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
</TableHead>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||||
<span>수정 (U)</span>
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<Checkbox
|
<span>수정 (U)</span>
|
||||||
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
|
<Checkbox
|
||||||
className="mt-1"
|
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
|
||||||
/>
|
className="data-[state=checked]:bg-amber-600"
|
||||||
</div>
|
/>
|
||||||
</TableHead>
|
</div>
|
||||||
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
|
</TableHead>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
|
||||||
<span>삭제 (D)</span>
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<Checkbox
|
<span>삭제 (D)</span>
|
||||||
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
<Checkbox
|
||||||
className="mt-1"
|
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
||||||
/>
|
className="data-[state=checked]:bg-red-600"
|
||||||
</div>
|
/>
|
||||||
</TableHead>
|
</div>
|
||||||
</TableRow>
|
</TableHead>
|
||||||
</TableHeader>
|
</TableRow>
|
||||||
<TableBody>{menuTree.map((menu) => renderMenuRow(menu))}</TableBody>
|
</TableHeader>
|
||||||
</Table>
|
<TableBody>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-32 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
<p className="text-sm text-muted-foreground">메뉴 권한 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
menuTree.map((menu) => renderMenuRow(menu))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 모바일 카드 뷰 */}
|
{/* 모바일 카드 뷰 */}
|
||||||
<div className="grid gap-4 lg:hidden">
|
{menuTree.length > 0 && (
|
||||||
{menuTree.map((menu) => (
|
<div className="grid gap-3 lg:hidden">
|
||||||
<div key={menu.menuObjid} className="bg-card p-4 shadow-sm">
|
{menuTree.map((menu) => {
|
||||||
<h3 className="mb-3 text-base font-semibold">{menu.menuName}</h3>
|
const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
return (
|
||||||
<span className="text-muted-foreground">생성 (C)</span>
|
<div
|
||||||
<Checkbox
|
key={menu.menuObjid}
|
||||||
checked={menu.createYn === "Y"}
|
className={cn(
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
"bg-card rounded-lg border p-4 shadow-sm",
|
||||||
/>
|
hasAnyPermission && "border-l-4 border-l-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<h3 className={cn("text-sm font-semibold", hasAnyPermission && "text-primary")}>
|
||||||
|
{menu.menuName}
|
||||||
|
</h3>
|
||||||
|
{menu.companyCode && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 text-[10px] font-medium rounded",
|
||||||
|
menu.companyCode === "*"
|
||||||
|
? "bg-primary/10 text-primary border border-primary/20"
|
||||||
|
: "bg-muted text-muted-foreground border border-border"
|
||||||
|
)}
|
||||||
|
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
|
||||||
|
>
|
||||||
|
{getCompanyLabel(menu.companyCode)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성 (C)</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={menu.createYn === "Y"}
|
||||||
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
||||||
|
className="data-[state=checked]:bg-green-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">조회 (R)</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={menu.readYn === "Y"}
|
||||||
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
||||||
|
className="data-[state=checked]:bg-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">수정 (U)</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={menu.updateYn === "Y"}
|
||||||
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
|
||||||
|
className="data-[state=checked]:bg-amber-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">삭제 (D)</span>
|
||||||
|
<Checkbox
|
||||||
|
checked={menu.deleteYn === "Y"}
|
||||||
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
||||||
|
className="data-[state=checked]:bg-red-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
);
|
||||||
<span className="text-muted-foreground">조회 (R)</span>
|
})}
|
||||||
<Checkbox
|
</div>
|
||||||
checked={menu.readYn === "Y"}
|
)}
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">수정 (U)</span>
|
|
||||||
<Checkbox
|
|
||||||
checked={menu.updateYn === "Y"}
|
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">삭제 (D)</span>
|
|
||||||
<Checkbox
|
|
||||||
checked={menu.deleteYn === "Y"}
|
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,33 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Loader2, Copy } from "lucide-react";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle } from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
||||||
|
interface LinkedModalScreen {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
newScreenName?: string;
|
||||||
|
newScreenCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyInfo {
|
||||||
|
companyCode: string;
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CopyScreenModalProps {
|
interface CopyScreenModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -25,36 +48,279 @@ interface CopyScreenModalProps {
|
||||||
onCopySuccess: () => void;
|
onCopySuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopySuccess }: CopyScreenModalProps) {
|
export default function CopyScreenModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
sourceScreen,
|
||||||
|
onCopySuccess,
|
||||||
|
}: CopyScreenModalProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
// 최고 관리자 판별: userType이 "SUPER_ADMIN" 또는 companyCode가 "*"
|
||||||
|
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
||||||
|
|
||||||
|
// 디버깅: 사용자 정보 확인
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 CopyScreenModal - User Info:", {
|
||||||
|
user,
|
||||||
|
isSuperAdmin,
|
||||||
|
userType: user?.userType,
|
||||||
|
companyCode: user?.companyCode,
|
||||||
|
조건1: user?.userType === "SUPER_ADMIN",
|
||||||
|
조건2: user?.companyCode === "*",
|
||||||
|
최종판별: user?.userType === "SUPER_ADMIN" || user?.companyCode === "*",
|
||||||
|
});
|
||||||
|
}, [user, isSuperAdmin]);
|
||||||
|
|
||||||
|
// 메인 화면 복사 정보
|
||||||
const [screenName, setScreenName] = useState("");
|
const [screenName, setScreenName] = useState("");
|
||||||
const [screenCode, setScreenCode] = useState("");
|
const [screenCode, setScreenCode] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 대상 회사 선택 (최고 관리자 전용)
|
||||||
|
const [targetCompanyCode, setTargetCompanyCode] = useState<string>("");
|
||||||
|
const [companies, setCompanies] = useState<CompanyInfo[]>([]);
|
||||||
|
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||||
|
|
||||||
|
// 연결된 모달 화면들
|
||||||
|
const [linkedScreens, setLinkedScreens] = useState<LinkedModalScreen[]>([]);
|
||||||
|
const [loadingLinkedScreens, setLoadingLinkedScreens] = useState(false);
|
||||||
|
|
||||||
|
// 화면명 일괄 수정 기능
|
||||||
|
const [useBulkRename, setUseBulkRename] = useState(false);
|
||||||
|
const [removeText, setRemoveText] = useState("");
|
||||||
|
const [addPrefix, setAddPrefix] = useState("");
|
||||||
|
|
||||||
|
// 복사 중 상태
|
||||||
const [isCopying, setIsCopying] = useState(false);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
|
||||||
// 모달이 열릴 때 초기값 설정
|
// 최고 관리자인 경우 회사 목록 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("🔍 회사 목록 조회 체크:", { isSuperAdmin, isOpen });
|
||||||
|
if (isSuperAdmin && isOpen) {
|
||||||
|
console.log("✅ 회사 목록 조회 시작");
|
||||||
|
loadCompanies();
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin, isOpen]);
|
||||||
|
|
||||||
|
// 모달이 열릴 때 초기값 설정 및 연결된 화면 감지
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 모달 초기화:", { isOpen, sourceScreen, isSuperAdmin });
|
||||||
if (isOpen && sourceScreen) {
|
if (isOpen && sourceScreen) {
|
||||||
|
// 메인 화면 정보 설정
|
||||||
setScreenName(`${sourceScreen.screenName} (복사본)`);
|
setScreenName(`${sourceScreen.screenName} (복사본)`);
|
||||||
setDescription(sourceScreen.description || "");
|
setDescription(sourceScreen.description || "");
|
||||||
// 화면 코드 자동 생성
|
|
||||||
generateNewScreenCode();
|
|
||||||
}
|
|
||||||
}, [isOpen, sourceScreen]);
|
|
||||||
|
|
||||||
// 새로운 화면 코드 자동 생성
|
// 대상 회사 코드 설정
|
||||||
const generateNewScreenCode = async () => {
|
if (isSuperAdmin) {
|
||||||
if (!sourceScreen?.companyCode) return;
|
setTargetCompanyCode(sourceScreen.companyCode); // 기본값: 원본과 같은 회사
|
||||||
|
} else {
|
||||||
|
setTargetCompanyCode(sourceScreen.companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결된 모달 화면 감지
|
||||||
|
console.log("✅ 연결된 모달 화면 감지 시작");
|
||||||
|
detectLinkedModals();
|
||||||
|
}
|
||||||
|
}, [isOpen, sourceScreen, isSuperAdmin]);
|
||||||
|
|
||||||
|
// 일괄 변경 설정이 변경될 때 화면명 자동 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceScreen) return;
|
||||||
|
|
||||||
|
if (useBulkRename) {
|
||||||
|
// 일괄 수정 사용 시: (복사본) 텍스트 제거
|
||||||
|
const newMainName = applyBulkRename(sourceScreen.screenName);
|
||||||
|
setScreenName(newMainName);
|
||||||
|
|
||||||
|
// 모달 화면명 업데이트
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((screen) => ({
|
||||||
|
...screen,
|
||||||
|
newScreenName: applyBulkRename(screen.screenName),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 일괄 수정 미사용 시: (복사본) 텍스트 추가
|
||||||
|
setScreenName(`${sourceScreen.screenName} (복사본)`);
|
||||||
|
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((screen) => ({
|
||||||
|
...screen,
|
||||||
|
newScreenName: screen.screenName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [useBulkRename, removeText, addPrefix]);
|
||||||
|
|
||||||
|
// 대상 회사 변경 시 기존 코드 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetCompanyCode) {
|
||||||
|
console.log("🔄 회사 변경 → 기존 코드 초기화:", targetCompanyCode);
|
||||||
|
setScreenCode("");
|
||||||
|
// 모달 화면들의 코드도 초기화
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((screen) => ({ ...screen, newScreenCode: undefined }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [targetCompanyCode]);
|
||||||
|
|
||||||
|
// linkedScreens 로딩이 완료되면 화면 코드 생성
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 코드 생성 조건 체크:", {
|
||||||
|
targetCompanyCode,
|
||||||
|
loadingLinkedScreens,
|
||||||
|
screenCode,
|
||||||
|
linkedScreensCount: linkedScreens.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
|
||||||
|
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
|
||||||
|
generateScreenCodes();
|
||||||
|
}
|
||||||
|
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
|
||||||
|
|
||||||
|
// 회사 목록 조회
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingCompanies(true);
|
||||||
|
const response = await apiClient.get("/admin/companies");
|
||||||
|
const data = response.data.data || response.data || [];
|
||||||
|
setCompanies(data.map((c: any) => ({
|
||||||
|
companyCode: c.company_code || c.companyCode,
|
||||||
|
companyName: c.company_name || c.companyName,
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 목록 조회 실패:", error);
|
||||||
|
toast.error("회사 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoadingCompanies(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결된 모달 화면 감지
|
||||||
|
const detectLinkedModals = async () => {
|
||||||
|
if (!sourceScreen) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode);
|
setLoadingLinkedScreens(true);
|
||||||
setScreenCode(newCode);
|
console.log("📡 API 호출: detectLinkedModals", sourceScreen.screenId);
|
||||||
|
const linked = await screenApi.detectLinkedModals(sourceScreen.screenId);
|
||||||
|
console.log("✅ 연결된 모달 화면 감지 결과:", linked);
|
||||||
|
|
||||||
|
// 초기 newScreenName 설정
|
||||||
|
setLinkedScreens(
|
||||||
|
linked.map((screen) => ({
|
||||||
|
...screen,
|
||||||
|
newScreenName: `${screen.screenName} (복사본)`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (linked.length > 0) {
|
||||||
|
toast.info(`${linked.length}개의 연결된 모달 화면을 감지했습니다.`);
|
||||||
|
console.log("🎉 감지된 화면들:", linked);
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ 연결된 모달 화면이 없습니다.");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("화면 코드 생성 실패:", error);
|
console.error("❌ 연결된 화면 감지 실패:", error);
|
||||||
|
// 에러가 나도 진행 가능하도록 무시
|
||||||
|
} finally {
|
||||||
|
setLoadingLinkedScreens(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 코드 자동 생성 (메인 + 모달 화면들) - 일괄 생성으로 중복 방지
|
||||||
|
const generateScreenCodes = async () => {
|
||||||
|
if (!targetCompanyCode) {
|
||||||
|
console.log("❌ targetCompanyCode가 없어서 화면 코드 생성 중단");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 메인 화면 1개 + 연결된 모달 화면들 = 총 개수
|
||||||
|
const totalCount = 1 + linkedScreens.length;
|
||||||
|
console.log(`📡 화면 코드 일괄 생성 API 호출: ${targetCompanyCode}, 개수: ${totalCount}`);
|
||||||
|
|
||||||
|
// 한 번에 모든 코드 생성 (중복 방지)
|
||||||
|
const generatedCodes = await screenApi.generateMultipleScreenCodes(
|
||||||
|
targetCompanyCode,
|
||||||
|
totalCount
|
||||||
|
);
|
||||||
|
console.log("✅ 생성된 화면 코드들:", generatedCodes);
|
||||||
|
|
||||||
|
// 첫 번째 코드는 메인 화면용
|
||||||
|
setScreenCode(generatedCodes[0]);
|
||||||
|
console.log("✅ 메인 화면 코드:", generatedCodes[0]);
|
||||||
|
|
||||||
|
// 나머지 코드들은 모달 화면들에 순서대로 할당
|
||||||
|
if (linkedScreens.length > 0) {
|
||||||
|
const updatedLinkedScreens = linkedScreens.map((screen, index) => ({
|
||||||
|
...screen,
|
||||||
|
newScreenCode: generatedCodes[index + 1], // 1번째부터 시작
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLinkedScreens(updatedLinkedScreens);
|
||||||
|
console.log("✅ 모달 화면 코드 할당 완료:", updatedLinkedScreens.map(s => ({
|
||||||
|
name: s.screenName,
|
||||||
|
code: s.newScreenCode
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 화면 코드 일괄 생성 실패:", error);
|
||||||
toast.error("화면 코드 생성에 실패했습니다.");
|
toast.error("화면 코드 생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연결된 화면 이름 변경
|
||||||
|
const updateLinkedScreenName = (screenId: number, newName: string) => {
|
||||||
|
setLinkedScreens((prev) =>
|
||||||
|
prev.map((screen) =>
|
||||||
|
screen.screenId === screenId ? { ...screen, newScreenName: newName } : screen
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결된 화면 제거 (복사하지 않음)
|
||||||
|
const removeLinkedScreen = (screenId: number) => {
|
||||||
|
setLinkedScreens((prev) => prev.filter((screen) => screen.screenId !== screenId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면명 일괄 변경 적용
|
||||||
|
const applyBulkRename = (originalName: string): string => {
|
||||||
|
if (!useBulkRename) return originalName;
|
||||||
|
|
||||||
|
let newName = originalName;
|
||||||
|
|
||||||
|
// 1. 제거할 텍스트 제거
|
||||||
|
if (removeText.trim()) {
|
||||||
|
newName = newName.replace(new RegExp(removeText.trim(), "g"), "");
|
||||||
|
newName = newName.trim(); // 앞뒤 공백 제거
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 접두사 추가
|
||||||
|
if (addPrefix.trim()) {
|
||||||
|
newName = addPrefix.trim() + " " + newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 미리보기: 변경될 화면명들
|
||||||
|
const getPreviewNames = () => {
|
||||||
|
if (!sourceScreen || !useBulkRename) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
main: {
|
||||||
|
original: sourceScreen.screenName,
|
||||||
|
preview: applyBulkRename(sourceScreen.screenName), // (복사본) 없음
|
||||||
|
},
|
||||||
|
modals: linkedScreens.map((screen) => ({
|
||||||
|
original: screen.screenName,
|
||||||
|
preview: applyBulkRename(screen.screenName),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 화면 복사 실행
|
// 화면 복사 실행
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (!sourceScreen) return;
|
if (!sourceScreen) return;
|
||||||
|
|
@ -70,21 +336,75 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 연결된 화면들의 이름 검증
|
||||||
|
for (const linked of linkedScreens) {
|
||||||
|
if (!linked.newScreenName?.trim()) {
|
||||||
|
toast.error(`"${linked.screenName}" 모달 화면의 새 이름을 입력해주세요.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linked.newScreenCode?.trim()) {
|
||||||
|
toast.error(`"${linked.screenName}" 모달 화면의 코드가 생성되지 않았습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsCopying(true);
|
setIsCopying(true);
|
||||||
|
|
||||||
// 화면 복사 API 호출
|
// 화면명 중복 체크
|
||||||
await screenApi.copyScreen(sourceScreen.screenId, {
|
const companyCode = targetCompanyCode || sourceScreen.companyCode;
|
||||||
screenName: screenName.trim(),
|
|
||||||
screenCode: screenCode.trim(),
|
// 메인 화면명 중복 체크
|
||||||
description: description.trim(),
|
const isMainDuplicate = await screenApi.checkDuplicateScreenName(
|
||||||
|
companyCode,
|
||||||
|
screenName.trim()
|
||||||
|
);
|
||||||
|
if (isMainDuplicate) {
|
||||||
|
toast.error(`"${screenName}" 화면명이 이미 존재합니다. 다른 이름을 입력해주세요.`);
|
||||||
|
setIsCopying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 화면명 중복 체크
|
||||||
|
for (const linked of linkedScreens) {
|
||||||
|
const isModalDuplicate = await screenApi.checkDuplicateScreenName(
|
||||||
|
companyCode,
|
||||||
|
linked.newScreenName!.trim()
|
||||||
|
);
|
||||||
|
if (isModalDuplicate) {
|
||||||
|
toast.error(
|
||||||
|
`"${linked.newScreenName}" 화면명이 이미 존재합니다. 모달 화면의 이름을 변경해주세요.`
|
||||||
|
);
|
||||||
|
setIsCopying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 화면 + 모달 화면들 일괄 복사
|
||||||
|
const result = await screenApi.copyScreenWithModals(sourceScreen.screenId, {
|
||||||
|
targetCompanyCode: targetCompanyCode || undefined, // 최고 관리자: 대상 회사 전달
|
||||||
|
mainScreen: {
|
||||||
|
screenName: screenName.trim(),
|
||||||
|
screenCode: screenCode.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
},
|
||||||
|
modalScreens: linkedScreens.map((screen) => ({
|
||||||
|
sourceScreenId: screen.screenId,
|
||||||
|
screenName: screen.newScreenName!.trim(),
|
||||||
|
screenCode: screen.newScreenCode!.trim(),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("화면이 성공적으로 복사되었습니다.");
|
console.log("✅ 복사 완료:", result);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)`
|
||||||
|
);
|
||||||
|
|
||||||
onCopySuccess();
|
onCopySuccess();
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// console.error("화면 복사 실패:", error);
|
console.error("화면 복사 실패:", error);
|
||||||
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
|
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -97,19 +417,26 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
setScreenName("");
|
setScreenName("");
|
||||||
setScreenCode("");
|
setScreenCode("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setTargetCompanyCode("");
|
||||||
|
setLinkedScreens([]);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Copy className="h-5 w-5" />
|
<Copy className="h-5 w-5" />
|
||||||
화면 복사
|
화면 복사
|
||||||
|
{linkedScreens.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({linkedScreens.length}개의 모달 화면 포함)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다.
|
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성과 연결된 모달 화면도 함께 복사됩니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -130,8 +457,116 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 새 화면 정보 입력 */}
|
{/* 최고 관리자: 대상 회사 선택 */}
|
||||||
<div className="space-y-3">
|
{isSuperAdmin && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="targetCompany">복사 대상 회사 * (최고 관리자 전용)</Label>
|
||||||
|
<Select value={targetCompanyCode} onValueChange={setTargetCompanyCode}>
|
||||||
|
<SelectTrigger id="targetCompany" className="mt-1">
|
||||||
|
<SelectValue placeholder="회사를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{loadingCompanies ? (
|
||||||
|
<div className="p-2 text-center text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
companies.map((company) => (
|
||||||
|
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||||
|
{company.companyName} ({company.companyCode})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
선택한 회사로 화면이 복사됩니다. 원본과 다른 회사를 선택하면 회사 간 화면 복사가 가능합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면명 일괄 수정 */}
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="useBulkRename"
|
||||||
|
checked={useBulkRename}
|
||||||
|
onChange={(e) => setUseBulkRename(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="useBulkRename" className="text-sm font-medium text-blue-900 cursor-pointer">
|
||||||
|
🔄 화면명 일괄 수정 (선택사항)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useBulkRename && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="removeText" className="text-xs text-blue-900">
|
||||||
|
제거할 텍스트
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="removeText"
|
||||||
|
value={removeText}
|
||||||
|
onChange={(e) => setRemoveText(e.target.value)}
|
||||||
|
placeholder="예: 탑씰"
|
||||||
|
className="mt-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="addPrefix" className="text-xs text-blue-900">
|
||||||
|
추가할 접두사
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addPrefix"
|
||||||
|
value={addPrefix}
|
||||||
|
onChange={(e) => setAddPrefix(e.target.value)}
|
||||||
|
placeholder="예: 대진산업"
|
||||||
|
className="mt-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
{(removeText || addPrefix) && getPreviewNames() && (
|
||||||
|
<div className="rounded-md border border-blue-300 bg-white p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium text-blue-900">미리보기</p>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{/* 메인 화면 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
메인: <span className="line-through">{getPreviewNames()?.main.original}</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-blue-700">
|
||||||
|
→ {getPreviewNames()?.main.preview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 모달 화면들 */}
|
||||||
|
{getPreviewNames()?.modals.map((modal, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
모달: <span className="line-through">{modal.original}</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-blue-700">→ {modal.preview}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
💡 모든 화면명에서 "제거할 텍스트"를 삭제하고 "추가할 접두사"를 앞에 붙입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 화면 정보 입력 */}
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<h4 className="text-sm font-medium">메인 화면 정보</h4>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="screenName">새 화면명 *</Label>
|
<Label htmlFor="screenName">새 화면명 *</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -166,13 +601,79 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 연결된 모달 화면 목록 */}
|
||||||
|
{loadingLinkedScreens ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-lg border p-6">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">연결된 모달 화면 감지 중...</span>
|
||||||
|
</div>
|
||||||
|
) : linkedScreens.length > 0 ? (
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
연결된 모달 화면 ({linkedScreens.length}개)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">함께 복사됩니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{linkedScreens.map((linkedScreen) => (
|
||||||
|
<div
|
||||||
|
key={linkedScreen.screenId}
|
||||||
|
className="space-y-2 rounded-md border bg-gray-50 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
원본: {linkedScreen.screenName} ({linkedScreen.screenCode})
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={linkedScreen.newScreenName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLinkedScreenName(linkedScreen.screenId, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="복사될 모달 화면 이름"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={linkedScreen.newScreenCode || ""}
|
||||||
|
readOnly
|
||||||
|
className="h-8 bg-white text-xs"
|
||||||
|
placeholder="코드 자동 생성"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => removeLinkedScreen(linkedScreen.screenId)}
|
||||||
|
title="이 화면은 복사하지 않음"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
모달 화면을 복사하지 않으려면 휴지통 아이콘을 클릭하세요. 버튼의 모달 연결이 자동으로
|
||||||
|
업데이트됩니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCopy} disabled={isCopying}>
|
<Button onClick={handleCopy} disabled={isCopying || !targetCompanyCode}>
|
||||||
{isCopying ? (
|
{isCopying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|
@ -182,6 +683,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
<>
|
<>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
복사하기
|
복사하기
|
||||||
|
{linkedScreens.length > 0 && ` (${linkedScreens.length + 1}개 화면)`}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
|
||||||
|
|
||||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
|
|
@ -582,6 +584,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("✅ 데이터 조회 완료:", {
|
||||||
|
tableName: component.tableName,
|
||||||
|
dataLength: result.data.length,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page
|
||||||
|
});
|
||||||
|
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setTotalPages(result.totalPages);
|
setTotalPages(result.totalPages);
|
||||||
|
|
@ -1952,7 +1961,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
|
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
|
||||||
switch (actualWebType) {
|
switch (actualWebType) {
|
||||||
case "category": {
|
case "category": {
|
||||||
// 카테고리 타입: 배지로 표시
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
|
|
@ -1962,6 +1971,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const displayLabel = categoryData?.label || String(value);
|
const displayLabel = categoryData?.label || String(value);
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
||||||
|
|
||||||
|
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
||||||
|
if (displayColor === "none") {
|
||||||
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -4210,6 +4210,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onCopyComponent={copyComponent}
|
onCopyComponent={copyComponent}
|
||||||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||||
currentTableName={selectedScreen?.tableName}
|
currentTableName={selectedScreen?.tableName}
|
||||||
|
currentScreenCompanyCode={selectedScreen?.companyCode}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
onStyleChange={(style) => {
|
onStyleChange={(style) => {
|
||||||
if (selectedComponent) {
|
if (selectedComponent) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface ButtonConfigPanelProps {
|
||||||
onUpdateProperty: (path: string, value: any) => void;
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
|
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
|
||||||
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
||||||
|
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenOption {
|
interface ScreenOption {
|
||||||
|
|
@ -34,6 +35,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
allComponents = [], // 🆕 기본값 빈 배열
|
allComponents = [], // 🆕 기본값 빈 배열
|
||||||
currentTableName, // 현재 화면의 테이블명
|
currentTableName, // 현재 화면의 테이블명
|
||||||
|
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
|
||||||
}) => {
|
}) => {
|
||||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||||
const config = component.componentConfig || {};
|
const config = component.componentConfig || {};
|
||||||
|
|
@ -96,17 +98,24 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [component.id]);
|
}, [component.id]);
|
||||||
|
|
||||||
// 화면 목록 가져오기 (전체 목록)
|
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchScreens = async () => {
|
const fetchScreens = async () => {
|
||||||
try {
|
try {
|
||||||
setScreensLoading(true);
|
setScreensLoading(true);
|
||||||
// 전체 목록을 가져오기 위해 size를 큰 값으로 설정
|
// 현재 편집 중인 화면의 회사 코드 기준으로 화면 목록 조회
|
||||||
|
const params: any = {
|
||||||
|
page: 1,
|
||||||
|
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달
|
||||||
|
if (currentScreenCompanyCode) {
|
||||||
|
params.companyCode = currentScreenCompanyCode;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.get("/screen-management/screens", {
|
const response = await apiClient.get("/screen-management/screens", {
|
||||||
params: {
|
params,
|
||||||
page: 1,
|
|
||||||
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success && Array.isArray(response.data.data)) {
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
|
|
@ -125,7 +134,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchScreens();
|
fetchScreens();
|
||||||
}, []);
|
}, [currentScreenCompanyCode]);
|
||||||
|
|
||||||
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -343,7 +352,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||||
"화면을 선택하세요..."
|
"화면을 선택하세요..."
|
||||||
: "화면을 선택하세요..."}
|
: "화면을 선택하세요..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
|
@ -382,7 +391,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
@ -418,7 +427,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||||
"수정 폼 화면을 선택하세요..."
|
"수정 폼 화면을 선택하세요..."
|
||||||
: "수정 폼 화면을 선택하세요..."}
|
: "수정 폼 화면을 선택하세요..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
|
@ -457,7 +466,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
@ -572,7 +581,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||||
"복사 폼 화면을 선택하세요..."
|
"복사 폼 화면을 선택하세요..."
|
||||||
: "복사 폼 화면을 선택하세요..."}
|
: "복사 폼 화면을 선택하세요..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
|
@ -611,7 +620,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
@ -790,7 +799,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
>
|
>
|
||||||
{config.action?.targetScreenId
|
{config.action?.targetScreenId
|
||||||
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||||
"화면을 선택하세요..."
|
"화면을 선택하세요..."
|
||||||
: "화면을 선택하세요..."}
|
: "화면을 선택하세요..."}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
|
@ -829,7 +838,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
|
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
||||||
|
import { UnifiedColumnInfo } from "@/types/table-management";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface DataFilterConfigPanelProps {
|
||||||
|
tableName?: string;
|
||||||
|
columns?: UnifiedColumnInfo[];
|
||||||
|
config?: DataFilterConfig;
|
||||||
|
onConfigChange: (config: DataFilterConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 필터 설정 패널
|
||||||
|
* 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용
|
||||||
|
*/
|
||||||
|
export function DataFilterConfigPanel({
|
||||||
|
tableName,
|
||||||
|
columns = [],
|
||||||
|
config,
|
||||||
|
onConfigChange,
|
||||||
|
}: DataFilterConfigPanelProps) {
|
||||||
|
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
|
||||||
|
config || {
|
||||||
|
enabled: false,
|
||||||
|
filters: [],
|
||||||
|
matchType: "all",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
|
||||||
|
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ value: string; label: string }>>>({});
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setLocalConfig(config);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 카테고리 값 로드
|
||||||
|
const loadCategoryValues = async (columnName: string) => {
|
||||||
|
if (!tableName || categoryValues[columnName] || loadingCategories[columnName]) {
|
||||||
|
return; // 이미 로드되었거나 로딩 중이면 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-categories/${tableName}/${columnName}/values`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const values = response.data.data.map((item: any) => ({
|
||||||
|
value: item.valueCode,
|
||||||
|
label: item.valueLabel,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnabledChange = (enabled: boolean) => {
|
||||||
|
const newConfig = { ...localConfig, enabled };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatchTypeChange = (matchType: "all" | "any") => {
|
||||||
|
const newConfig = { ...localConfig, matchType };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddFilter = () => {
|
||||||
|
const newFilter: ColumnFilter = {
|
||||||
|
id: `filter-${Date.now()}`,
|
||||||
|
columnName: columns[0]?.columnName || "",
|
||||||
|
operator: "equals",
|
||||||
|
value: "",
|
||||||
|
valueType: "static",
|
||||||
|
};
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: [...localConfig.filters, newFilter],
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFilter = (filterId: string) => {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.filter((f) => f.id !== filterId),
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.map((filter) =>
|
||||||
|
filter.id === filterId ? { ...filter, [field]: value } : filter
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 컬럼의 input_type 찾기 (데이터베이스의 실제 input_type)
|
||||||
|
const getColumnInputType = (columnName: string) => {
|
||||||
|
const column = columns.find((col) => col.columnName === columnName);
|
||||||
|
// input_type (소문자) 필드 사용 - 이것이 실제 카테고리/엔티티 타입 정보
|
||||||
|
return column?.input_type || column?.webType || "text";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리/코드 타입인지 확인
|
||||||
|
const isCategoryOrCodeColumn = (columnName: string) => {
|
||||||
|
const inputType = getColumnInputType(columnName);
|
||||||
|
return inputType === "category" || inputType === "code";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 필터 활성화 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">데이터 필터링</Label>
|
||||||
|
<Switch checked={localConfig.enabled} onCheckedChange={handleEnabledChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localConfig.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 테이블명 표시 */}
|
||||||
|
{tableName && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
테이블: <span className="font-medium">{tableName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 매칭 타입 */}
|
||||||
|
{localConfig.filters.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">조건 매칭</Label>
|
||||||
|
<Select value={localConfig.matchType} onValueChange={handleMatchTypeChange}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">모든 조건 만족 (AND)</SelectItem>
|
||||||
|
<SelectItem value="any">하나 이상 만족 (OR)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 목록 */}
|
||||||
|
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||||
|
{localConfig.filters.map((filter, index) => (
|
||||||
|
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
필터 {index + 1}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => handleRemoveFilter(filter.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.columnName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const column = columns.find((col) => col.columnName === value);
|
||||||
|
|
||||||
|
console.log("🔍 컬럼 선택:", {
|
||||||
|
columnName: value,
|
||||||
|
input_type: column?.input_type,
|
||||||
|
column,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 타입에 따라 valueType 자동 설정
|
||||||
|
let valueType: "static" | "category" | "code" = "static";
|
||||||
|
if (column?.input_type === "category") {
|
||||||
|
valueType = "category";
|
||||||
|
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||||
|
loadCategoryValues(value); // 카테고리 값 로드
|
||||||
|
} else if (column?.input_type === "code") {
|
||||||
|
valueType = "code";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한 번에 모든 변경사항 적용
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.map((f) =>
|
||||||
|
f.id === filter.id
|
||||||
|
? { ...f, columnName: value, valueType, value: "" }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("✅ 필터 설정 업데이트:", {
|
||||||
|
filterId: filter.id,
|
||||||
|
columnName: value,
|
||||||
|
valueType,
|
||||||
|
newConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
{(col.input_type === "category" || col.input_type === "code") && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({col.input_type})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연산자 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">연산자</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.operator}
|
||||||
|
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||||
|
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||||
|
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||||
|
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||||
|
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||||
|
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||||
|
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||||
|
<SelectItem value="is_null">NULL</SelectItem>
|
||||||
|
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
|
||||||
|
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">값 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.valueType}
|
||||||
|
onValueChange={(value: any) =>
|
||||||
|
handleFilterChange(filter.id, "valueType", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">직접 입력</SelectItem>
|
||||||
|
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||||
|
<SelectItem value="code">코드 선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 값 입력 (NULL 체크 제외) */}
|
||||||
|
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">값</Label>
|
||||||
|
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||||
|
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||||
|
<Select
|
||||||
|
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||||
|
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder={
|
||||||
|
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
|
||||||
|
} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categoryValues[filter.columnName].map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||||
|
<Input
|
||||||
|
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const values = e.target.value.split(",").map((v) => v.trim());
|
||||||
|
handleFilterChange(filter.id, "value", values);
|
||||||
|
}}
|
||||||
|
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||||
|
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||||
|
placeholder="필터 값 입력"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||||
|
? "카테고리 값을 선택하세요"
|
||||||
|
: filter.operator === "in" || filter.operator === "not_in"
|
||||||
|
? "여러 값은 쉼표(,)로 구분하세요"
|
||||||
|
: "필터링할 값을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={handleAddFilter}
|
||||||
|
disabled={columns.length === 0}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{columns.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
테이블을 먼저 선택해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { getFlowDefinitions } from "@/lib/api/flow";
|
||||||
import type { FlowDefinition } from "@/types/flow";
|
import type { FlowDefinition } from "@/types/flow";
|
||||||
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
|
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
interface FlowWidgetConfigPanelProps {
|
interface FlowWidgetConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
|
@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
|
{config.flowId && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="text-sm font-medium">데이터 필터링</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
특정 컬럼 값으로 플로우 데이터를 필터링합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={selectedFlow?.name}
|
||||||
|
columns={[]} // 플로우의 첫 번째 스텝 테이블 컬럼 정보 필요 (TODO: API 연동)
|
||||||
|
config={config.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => onChange({ ...config, dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ interface DetailSettingsPanelProps {
|
||||||
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||||
currentTableName?: string; // 현재 화면의 테이블명
|
currentTableName?: string; // 현재 화면의 테이블명
|
||||||
tables?: TableInfo[]; // 전체 테이블 목록
|
tables?: TableInfo[]; // 전체 테이블 목록
|
||||||
|
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
@ -50,6 +51,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
currentTable,
|
currentTable,
|
||||||
currentTableName,
|
currentTableName,
|
||||||
tables = [], // 기본값 빈 배열
|
tables = [], // 기본값 빈 배열
|
||||||
|
currentScreenCompanyCode,
|
||||||
}) => {
|
}) => {
|
||||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
@ -868,6 +870,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
component={selectedComponent}
|
component={selectedComponent}
|
||||||
onUpdateProperty={handleUpdateProperty}
|
onUpdateProperty={handleUpdateProperty}
|
||||||
currentTableName={currentTableName}
|
currentTableName={currentTableName}
|
||||||
|
currentScreenCompanyCode={currentScreenCompanyCode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ interface UnifiedPropertiesPanelProps {
|
||||||
allComponents?: ComponentData[];
|
allComponents?: ComponentData[];
|
||||||
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
||||||
menuObjid?: number;
|
menuObjid?: number;
|
||||||
|
// 🆕 현재 편집 중인 화면의 회사 코드
|
||||||
|
currentScreenCompanyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
|
|
@ -98,6 +100,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
onCopyComponent,
|
onCopyComponent,
|
||||||
currentTable,
|
currentTable,
|
||||||
currentTableName,
|
currentTableName,
|
||||||
|
currentScreenCompanyCode,
|
||||||
dragState,
|
dragState,
|
||||||
onStyleChange,
|
onStyleChange,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
|
@ -278,6 +281,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
onUpdateProperty={handleUpdateProperty}
|
onUpdateProperty={handleUpdateProperty}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
currentTableName={currentTableName}
|
currentTableName={currentTableName}
|
||||||
|
currentScreenCompanyCode={currentScreenCompanyCode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
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;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 컬럼 추가 다이얼로그
|
||||||
|
*
|
||||||
|
* 논리적 컬럼명과 물리적 컬럼명을 매핑하여 메뉴별로 독립적인 카테고리 관리 가능
|
||||||
|
*
|
||||||
|
* 2레벨 메뉴를 선택하면 해당 메뉴의 모든 하위 메뉴에서 사용 가능
|
||||||
|
*/
|
||||||
|
export function AddCategoryColumnDialog({
|
||||||
|
tableName,
|
||||||
|
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);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setPhysicalColumns(response.data.map((col: any) => col.columnName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
toast.error("컬럼 목록을 불러올 수 없습니다");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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()) {
|
||||||
|
toast.error("논리적 컬럼명을 입력해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!physicalColumnName) {
|
||||||
|
toast.error("실제 컬럼을 선택해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMenus.length === 0) {
|
||||||
|
toast.error("최소 하나 이상의 메뉴를 선택해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 선택된 각 메뉴에 대해 매핑 생성
|
||||||
|
const promises = selectedMenus.map((menuObjid) =>
|
||||||
|
createColumnMapping({
|
||||||
|
tableName,
|
||||||
|
logicalColumnName: logicalColumnName.trim(),
|
||||||
|
physicalColumnName,
|
||||||
|
menuObjid,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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("모든 메뉴에 대한 매핑 생성에 실패했습니다");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("컬럼 매핑 생성 실패:", error);
|
||||||
|
toast.error(error.message || "컬럼 매핑 생성 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setLogicalColumnName("");
|
||||||
|
setPhysicalColumnName("");
|
||||||
|
setDescription("");
|
||||||
|
setSelectedMenus([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
카테고리 컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
카테고리 컬럼 추가
|
||||||
|
</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>
|
||||||
|
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{physicalColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
테이블의 실제 컬럼명
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 논리적 컬럼명 입력 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
논리적 컬럼명 (메뉴별 식별용) *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={logicalColumnName}
|
||||||
|
onChange={(e) => setLogicalColumnName(e.target.value)}
|
||||||
|
placeholder="예: status_stock, status_sales"
|
||||||
|
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>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!logicalColumnName || !physicalColumnName || selectedMenus.length === 0 || loading}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{loading ? "추가 중..." : "추가"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
||||||
const [valueLabel, setValueLabel] = useState("");
|
const [valueLabel, setValueLabel] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [color, setColor] = useState("#3b82f6");
|
const [color, setColor] = useState("none");
|
||||||
|
|
||||||
// 라벨에서 코드 자동 생성
|
// 라벨에서 코드 자동 생성
|
||||||
const generateCode = (label: string): string => {
|
const generateCode = (label: string): string => {
|
||||||
|
|
@ -91,7 +91,7 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
// 초기화
|
// 초기화
|
||||||
setValueLabel("");
|
setValueLabel("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setColor("#3b82f6");
|
setColor("none");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -123,24 +123,41 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
||||||
<div className="mt-1.5 flex items-center gap-3">
|
<div className="mt-1.5 space-y-2">
|
||||||
<div className="grid grid-cols-9 gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{DEFAULT_COLORS.map((c) => (
|
<div className="grid grid-cols-9 gap-2">
|
||||||
<button
|
{DEFAULT_COLORS.map((c) => (
|
||||||
key={c}
|
<button
|
||||||
type="button"
|
key={c}
|
||||||
onClick={() => setColor(c)}
|
type="button"
|
||||||
className={`h-7 w-7 rounded-md border-2 transition-all ${
|
onClick={() => setColor(c)}
|
||||||
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
|
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}
|
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>
|
</div>
|
||||||
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
|
<button
|
||||||
미리보기
|
type="button"
|
||||||
</Badge>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,12 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
||||||
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
||||||
const [description, setDescription] = useState(value.description || "");
|
const [description, setDescription] = useState(value.description || "");
|
||||||
const [color, setColor] = useState(value.color || "#3b82f6");
|
const [color, setColor] = useState(value.color || "none");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValueLabel(value.valueLabel);
|
setValueLabel(value.valueLabel);
|
||||||
setDescription(value.description || "");
|
setDescription(value.description || "");
|
||||||
setColor(value.color || "#3b82f6");
|
setColor(value.color || "none");
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -100,24 +100,41 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
<Label className="text-xs sm:text-sm">배지 색상</Label>
|
||||||
<div className="mt-1.5 flex items-center gap-3">
|
<div className="mt-1.5 space-y-2">
|
||||||
<div className="grid grid-cols-9 gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{DEFAULT_COLORS.map((c) => (
|
<div className="grid grid-cols-9 gap-2">
|
||||||
<button
|
{DEFAULT_COLORS.map((c) => (
|
||||||
key={c}
|
<button
|
||||||
type="button"
|
key={c}
|
||||||
onClick={() => setColor(c)}
|
type="button"
|
||||||
className={`h-7 w-7 rounded-md border-2 transition-all ${
|
onClick={() => setColor(c)}
|
||||||
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
|
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}
|
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>
|
</div>
|
||||||
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
|
<button
|
||||||
미리보기
|
type="button"
|
||||||
</Badge>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -349,13 +349,18 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
{/* 색상 표시 (앞쪽으로 이동) */}
|
{/* 색상 표시 (배지 없음 옵션 지원) */}
|
||||||
{value.color && (
|
{value.color && value.color !== "none" && (
|
||||||
<div
|
<div
|
||||||
className="h-4 w-4 rounded-full border flex-shrink-0"
|
className="h-4 w-4 rounded-full border flex-shrink-0"
|
||||||
style={{ backgroundColor: value.color }}
|
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" : ""}`}>
|
<span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const dataApi = {
|
||||||
leftColumn: string,
|
leftColumn: string,
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: any,
|
leftValue?: any,
|
||||||
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
): Promise<any[]> => {
|
): Promise<any[]> => {
|
||||||
const response = await apiClient.get(`/data/join`, {
|
const response = await apiClient.get(`/data/join`, {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -70,6 +71,7 @@ export const dataApi = {
|
||||||
leftColumn,
|
leftColumn,
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
|
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const raw = response.data || {};
|
const raw = response.data || {};
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export const entityJoinApi = {
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
}>;
|
}>;
|
||||||
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
|
||||||
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
@ -103,6 +104,7 @@ export const entityJoinApi = {
|
||||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export interface MenuFormData {
|
||||||
status: string;
|
status: string;
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
langKey?: string; // 다국어 키 추가
|
langKey?: string; // 다국어 키 추가
|
||||||
|
screenCode?: string; // 화면 코드 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LangKey {
|
export interface LangKey {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,15 @@ export const screenApi = {
|
||||||
return response.data.data.screenCode;
|
return response.data.data.screenCode;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 여러 개의 화면 코드 일괄 생성
|
||||||
|
generateMultipleScreenCodes: async (companyCode: string, count: number): Promise<string[]> => {
|
||||||
|
const response = await apiClient.post("/screen-management/generate-screen-codes", {
|
||||||
|
companyCode,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return response.data.data.screenCodes;
|
||||||
|
},
|
||||||
|
|
||||||
// 화면 수정
|
// 화면 수정
|
||||||
updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
|
updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
|
||||||
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);
|
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);
|
||||||
|
|
@ -169,6 +178,23 @@ export const screenApi = {
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 연결된 모달 화면 감지
|
||||||
|
detectLinkedModals: async (
|
||||||
|
screenId: number,
|
||||||
|
): Promise<Array<{ screenId: number; screenName: string; screenCode: string }>> => {
|
||||||
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/linked-modals`);
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 화면명 중복 체크
|
||||||
|
checkDuplicateScreenName: async (companyCode: string, screenName: string): Promise<boolean> => {
|
||||||
|
const response = await apiClient.post("/screen-management/screens/check-duplicate-name", {
|
||||||
|
companyCode,
|
||||||
|
screenName,
|
||||||
|
});
|
||||||
|
return response.data.data.isDuplicate || false;
|
||||||
|
},
|
||||||
|
|
||||||
// 화면 복사 (화면정보 + 레이아웃 모두 복사)
|
// 화면 복사 (화면정보 + 레이아웃 모두 복사)
|
||||||
copyScreen: async (
|
copyScreen: async (
|
||||||
sourceScreenId: number,
|
sourceScreenId: number,
|
||||||
|
|
@ -181,6 +207,30 @@ export const screenApi = {
|
||||||
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData);
|
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 메인 화면 + 모달 화면들 일괄 복사
|
||||||
|
copyScreenWithModals: async (
|
||||||
|
sourceScreenId: number,
|
||||||
|
copyData: {
|
||||||
|
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
|
||||||
|
mainScreen: {
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
modalScreens: Array<{
|
||||||
|
sourceScreenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
mainScreen: ScreenDefinition;
|
||||||
|
modalScreens: ScreenDefinition[];
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy-with-modals`, copyData);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 템플릿 관련 API
|
// 템플릿 관련 API
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,120 @@ export async function reorderCategoryValues(orderedValueIds: number[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================================
|
||||||
|
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||||
|
// ================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @returns { logical_column: physical_column } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
export async function getColumnMapping(tableName: string, menuObjid: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}>(`/table-categories/column-mapping/${tableName}/${menuObjid}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("컬럼 매핑 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message, data: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 논리적 컬럼 목록 조회
|
||||||
|
*
|
||||||
|
* @param tableName - 테이블명
|
||||||
|
* @param menuObjid - 메뉴 OBJID
|
||||||
|
* @returns 논리적 컬럼 목록
|
||||||
|
*/
|
||||||
|
export async function getLogicalColumns(tableName: string, menuObjid: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{
|
||||||
|
mappingId: number;
|
||||||
|
logicalColumnName: string;
|
||||||
|
physicalColumnName: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}>(`/table-categories/logical-columns/${tableName}/${menuObjid}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("논리적 컬럼 목록 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message, data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 생성/수정
|
||||||
|
*
|
||||||
|
* @param data - 컬럼 매핑 정보
|
||||||
|
*/
|
||||||
|
export async function createColumnMapping(data: {
|
||||||
|
tableName: string;
|
||||||
|
logicalColumnName: string;
|
||||||
|
physicalColumnName: string;
|
||||||
|
menuObjid: number;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: any;
|
||||||
|
message: string;
|
||||||
|
}>("/table-categories/column-mapping", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("컬럼 매핑 생성 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 매핑 삭제
|
||||||
|
*
|
||||||
|
* @param mappingId - 매핑 ID
|
||||||
|
*/
|
||||||
|
export async function deleteColumnMapping(mappingId: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}>(`/table-categories/column-mapping/${mappingId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("컬럼 매핑 삭제 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
size: 100,
|
size: 100,
|
||||||
search: filters, // 필터 조건 전달
|
search: filters, // 필터 조건 전달
|
||||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||||
|
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
leftColumn,
|
leftColumn,
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
|
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
);
|
);
|
||||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import { Slider } from "@/components/ui/slider";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
interface SplitPanelLayoutConfigPanelProps {
|
interface SplitPanelLayoutConfigPanelProps {
|
||||||
config: SplitPanelLayoutConfig;
|
config: SplitPanelLayoutConfig;
|
||||||
|
|
@ -325,14 +326,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 설정 (Accordion) */}
|
{/* 좌측 패널 설정 */}
|
||||||
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="left-panel" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
|
||||||
좌측 패널 설정 (마스터)
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="overflow-visible">
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* 우측 패널 설정 (Accordion) */}
|
{/* 좌측 패널 데이터 필터링 */}
|
||||||
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="right-panel" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">좌측 패널 데이터 필터링</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
<p className="text-xs text-muted-foreground">
|
||||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})
|
특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다
|
||||||
</AccordionTrigger>
|
</p>
|
||||||
<AccordionContent className="overflow-visible">
|
<DataFilterConfigPanel
|
||||||
<div className="space-y-4 pt-2">
|
tableName={config.leftPanel?.tableName || screenTableName}
|
||||||
|
columns={leftTableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
} as any))}
|
||||||
|
config={config.leftPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 설정 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
<h3 className="text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "상세" : "조인"})</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* 레이아웃 설정 (Accordion) */}
|
{/* 우측 패널 데이터 필터링 */}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
<AccordionItem value="layout" className="border rounded-lg px-4">
|
<h3 className="text-sm font-semibold">우측 패널 데이터 필터링</h3>
|
||||||
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
|
<p className="text-xs text-muted-foreground">
|
||||||
레이아웃 설정
|
특정 컬럼 값으로 우측 패널 데이터를 필터링합니다
|
||||||
</AccordionTrigger>
|
</p>
|
||||||
<AccordionContent className="overflow-visible">
|
<DataFilterConfigPanel
|
||||||
<div className="space-y-4 pt-2">
|
tableName={config.rightPanel?.tableName}
|
||||||
|
columns={rightTableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
} as any))}
|
||||||
|
config={config.rightPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 설정 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||||
|
|
@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* SplitPanelLayout 컴포넌트 타입 정의
|
* SplitPanelLayout 컴포넌트 타입 정의
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
export interface SplitPanelLayoutConfig {
|
export interface SplitPanelLayoutConfig {
|
||||||
// 좌측 패널 설정
|
// 좌측 패널 설정
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
|
|
@ -52,6 +54,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
hoverable?: boolean; // 호버 효과
|
hoverable?: boolean; // 호버 효과
|
||||||
stickyHeader?: boolean; // 헤더 고정
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 우측 패널 설정
|
// 우측 패널 설정
|
||||||
|
|
@ -105,6 +110,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
hoverable?: boolean; // 호버 효과
|
hoverable?: boolean; // 호버 효과
|
||||||
stickyHeader?: boolean; // 헤더 고정
|
stickyHeader?: boolean; // 헤더 고정
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -802,6 +802,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const fetchTableDataInternal = useCallback(async () => {
|
const fetchTableDataInternal = useCallback(async () => {
|
||||||
|
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
isDesignMode,
|
||||||
|
currentPage,
|
||||||
|
});
|
||||||
|
|
||||||
if (!tableConfig.selectedTable || isDesignMode) {
|
if (!tableConfig.selectedTable || isDesignMode) {
|
||||||
setData([]);
|
setData([]);
|
||||||
setTotalPages(0);
|
setTotalPages(0);
|
||||||
|
|
@ -809,11 +815,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블명 확인 로그 (개발 시에만)
|
|
||||||
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
|
||||||
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
|
||||||
// console.log("🔍 전체 tableConfig:", tableConfig);
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -834,6 +835,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log("🔍 [TableList] API 호출 시작", {
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
page,
|
page,
|
||||||
|
|
@ -843,8 +852,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
search: filters,
|
search: filters,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||||
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 실제 데이터의 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 || []);
|
setData(response.data || []);
|
||||||
setTotalPages(response.totalPages || 0);
|
setTotalPages(response.totalPages || 0);
|
||||||
setTotalItems(response.total || 0);
|
setTotalItems(response.total || 0);
|
||||||
|
|
@ -1354,28 +1375,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입: 배지로 표시
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
||||||
if (inputType === "category") {
|
if (inputType === "category") {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
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 displayLabel = categoryData?.label || String(value);
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
||||||
|
|
||||||
|
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
||||||
|
if (displayColor === "none") {
|
||||||
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
const { Badge } = require("@/components/ui/badge");
|
const { Badge } = require("@/components/ui/badge");
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -1716,6 +1731,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
||||||
|
isDesignMode,
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
currentPage,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isDesignMode && tableConfig.selectedTable) {
|
if (!isDesignMode && tableConfig.selectedTable) {
|
||||||
fetchTableDataDebounced();
|
fetchTableDataDebounced();
|
||||||
}
|
}
|
||||||
|
|
@ -1730,7 +1753,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
refreshKey,
|
refreshKey,
|
||||||
refreshTrigger, // 강제 새로고침 트리거
|
refreshTrigger, // 강제 새로고침 트리거
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
fetchTableDataDebounced,
|
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2157,9 +2180,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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) => {
|
groupedData.map((group) => {
|
||||||
|
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
|
||||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={group.groupKey}>
|
<React.Fragment key={group.groupKey}>
|
||||||
|
|
@ -2252,7 +2284,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
// 일반 렌더링 (그룹 없음)
|
// 일반 렌더링 (그룹 없음)
|
||||||
data.map((row, index) => (
|
(() => {
|
||||||
|
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
|
||||||
|
return data;
|
||||||
|
})().map((row, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
export interface TableListConfigPanelProps {
|
export interface TableListConfigPanelProps {
|
||||||
config: TableListConfig;
|
config: TableListConfig;
|
||||||
|
|
@ -47,7 +48,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [availableColumns, setAvailableColumns] = useState<
|
const [availableColumns, setAvailableColumns] = useState<
|
||||||
Array<{ columnName: string; dataType: string; label?: string }>
|
Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>
|
||||||
>([]);
|
>([]);
|
||||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||||
availableColumns: Array<{
|
availableColumns: Array<{
|
||||||
|
|
@ -157,6 +158,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
columnName: column.columnName || column.name,
|
columnName: column.columnName || column.name,
|
||||||
dataType: column.dataType || column.type || "text",
|
dataType: column.dataType || column.type || "text",
|
||||||
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
||||||
|
input_type: column.input_type || column.inputType, // 🆕 input_type 추가
|
||||||
}));
|
}));
|
||||||
setAvailableColumns(mappedColumns);
|
setAvailableColumns(mappedColumns);
|
||||||
|
|
||||||
|
|
@ -189,6 +191,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
label: col.displayName || col.columnName,
|
label: col.displayName || col.columnName,
|
||||||
|
input_type: col.input_type || col.inputType, // 🆕 input_type 추가
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1140,6 +1143,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 데이터 필터링 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">데이터 필터링</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
특정 컬럼 값으로 데이터를 필터링합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={config.selectedTable || screenTableName}
|
||||||
|
columns={availableColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.label || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
input_type: col.input_type, // 🆕 실제 input_type 전달
|
||||||
|
} as any))}
|
||||||
|
config={config.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,9 @@ export interface CheckboxConfig {
|
||||||
/**
|
/**
|
||||||
* TableList 컴포넌트 설정 타입
|
* TableList 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
export interface TableListConfig extends ComponentConfig {
|
export interface TableListConfig extends ComponentConfig {
|
||||||
// 표시 모드 설정
|
// 표시 모드 설정
|
||||||
displayMode?: "table" | "card"; // 기본: "table"
|
displayMode?: "table" | "card"; // 기본: "table"
|
||||||
|
|
@ -225,6 +228,9 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
autoLoad: boolean;
|
autoLoad: boolean;
|
||||||
refreshInterval?: number; // 초 단위
|
refreshInterval?: number; // 초 단위
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 이벤트 핸들러
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
onRowDoubleClick?: (row: any) => void;
|
onRowDoubleClick?: (row: any) => void;
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ export interface DataTableComponent extends BaseComponent {
|
||||||
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
|
||||||
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -173,6 +176,8 @@ export interface FlowComponent extends BaseComponent {
|
||||||
stepColumnConfig?: {
|
stepColumnConfig?: {
|
||||||
[stepId: number]: FlowStepColumnConfig;
|
[stepId: number]: FlowStepColumnConfig;
|
||||||
};
|
};
|
||||||
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -435,6 +440,26 @@ export interface DataTableFilter {
|
||||||
logicalOperator?: "AND" | "OR";
|
logicalOperator?: "AND" | "OR";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 필터 조건 (단일 필터)
|
||||||
|
*/
|
||||||
|
export interface ColumnFilter {
|
||||||
|
id: string;
|
||||||
|
columnName: string; // 필터링할 컬럼명
|
||||||
|
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
||||||
|
value: string | string[]; // 필터 값 (in/not_in은 배열)
|
||||||
|
valueType: "static" | "category" | "code"; // 값 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 필터 설정 (여러 필터의 조합)
|
||||||
|
*/
|
||||||
|
export interface DataFilterConfig {
|
||||||
|
enabled: boolean; // 필터 활성화 여부
|
||||||
|
filters: ColumnFilter[]; // 필터 조건 목록
|
||||||
|
matchType: "all" | "any"; // AND(모두 만족) / OR(하나 이상 만족)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 파일 업로드 관련 =====
|
// ===== 파일 업로드 관련 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue