연쇄 통합관리
This commit is contained in:
@@ -77,6 +77,10 @@ import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이
|
|||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
|
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||||
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
@@ -249,6 +253,10 @@ app.use("/api/orders", orderRoutes); // 수주 관리
|
|||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
|
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||||
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|||||||
@@ -1256,8 +1256,17 @@ export async function updateMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestCompanyCode =
|
let requestCompanyCode =
|
||||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
menuData.companyCode || menuData.company_code;
|
||||||
|
|
||||||
|
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||||
|
if (
|
||||||
|
requestCompanyCode === "none" ||
|
||||||
|
requestCompanyCode === "" ||
|
||||||
|
!requestCompanyCode
|
||||||
|
) {
|
||||||
|
requestCompanyCode = currentMenu.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
// company_code 변경 시도하는 경우 권한 체크
|
// company_code 변경 시도하는 경우 권한 체크
|
||||||
if (requestCompanyCode !== currentMenu.company_code) {
|
if (requestCompanyCode !== currentMenu.company_code) {
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 컨트롤러
|
||||||
|
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
g.*,
|
||||||
|
COUNT(m.mapping_id) as mapping_count
|
||||||
|
FROM cascading_auto_fill_group g
|
||||||
|
LEFT JOIN cascading_auto_fill_mapping m
|
||||||
|
ON g.group_code = m.group_code AND g.company_code = m.company_code
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_group
|
||||||
|
WHERE group_code = $1
|
||||||
|
`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupResult = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!groupResult) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order, mapping_id
|
||||||
|
`;
|
||||||
|
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...groupResult,
|
||||||
|
mappings: mappingResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "AF";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
mappings = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !masterTable || !masterValueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateAutoFillGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name, description,
|
||||||
|
master_table, master_value_column, master_label_column,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const groupResult = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 생성
|
||||||
|
if (mappings.length > 0) {
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 생성되었습니다.",
|
||||||
|
data: groupResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
mappings,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 업데이트
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_auto_fill_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
master_table = COALESCE($3, master_table),
|
||||||
|
master_value_column = COALESCE($4, master_value_column),
|
||||||
|
master_label_column = COALESCE($5, master_label_column),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $7 AND company_code = $8
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateResult = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 업데이트 (전체 교체 방식)
|
||||||
|
if (mappings !== undefined) {
|
||||||
|
// 기존 매핑 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
|
||||||
|
[groupCode, existing.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 매핑 추가
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 수정되었습니다.",
|
||||||
|
data: updateResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const deleteParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 옵션 목록 조회
|
||||||
|
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||||
|
*/
|
||||||
|
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 옵션 조회
|
||||||
|
const labelColumn = group.master_label_column || group.master_value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${group.master_value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
// company_code 컬럼 존재 여부 확인
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 데이터 조회
|
||||||
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const { masterValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!masterValue) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "masterValue 파라미터가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order
|
||||||
|
`;
|
||||||
|
const mappings = await query(mappingSql, [groupCode, group.company_code]);
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {},
|
||||||
|
mappings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 데이터 조회
|
||||||
|
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
|
||||||
|
let dataSql = `
|
||||||
|
SELECT ${sourceColumns}
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE ${group.master_value_column} = $1
|
||||||
|
`;
|
||||||
|
const dataParams: any[] = [masterValue];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
dataSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
dataParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataResult = await queryOne(dataSql, dataParams);
|
||||||
|
|
||||||
|
// 결과를 target_field 기준으로 변환
|
||||||
|
const autoFillData: Record<string, any> = {};
|
||||||
|
const mappingInfo: any[] = [];
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const sourceValue = dataResult?.[mapping.source_column];
|
||||||
|
const finalValue = sourceValue !== null && sourceValue !== undefined
|
||||||
|
? sourceValue
|
||||||
|
: mapping.default_value;
|
||||||
|
|
||||||
|
autoFillData[mapping.target_field] = finalValue;
|
||||||
|
mappingInfo.push({
|
||||||
|
targetField: mapping.target_field,
|
||||||
|
targetLabel: mapping.target_label,
|
||||||
|
value: finalValue,
|
||||||
|
isEditable: mapping.is_editable === "Y",
|
||||||
|
isRequired: mapping.is_required === "Y",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: autoFillData,
|
||||||
|
mappings: mappingInfo,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 데이터 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||||
|
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getConditions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, relationCode, relationType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드 필터
|
||||||
|
if (relationCode) {
|
||||||
|
sql += ` AND relation_code = $${paramIndex++}`;
|
||||||
|
params.push(relationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||||
|
if (relationType) {
|
||||||
|
sql += ` AND relation_type = $${paramIndex++}`;
|
||||||
|
params.push(relationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
|
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getConditionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const params: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
relationType = "RELATION",
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator = "EQ",
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority = 0,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_type, relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, priority,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
relationType,
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_condition SET
|
||||||
|
condition_name = COALESCE($1, condition_name),
|
||||||
|
condition_field = COALESCE($2, condition_field),
|
||||||
|
condition_operator = COALESCE($3, condition_operator),
|
||||||
|
condition_value = COALESCE($4, condition_value),
|
||||||
|
filter_column = COALESCE($5, filter_column),
|
||||||
|
filter_values = COALESCE($6, filter_values),
|
||||||
|
priority = COALESCE($7, priority),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE condition_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
Number(conditionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING condition_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
|
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getFilteredOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { relationCode } = req.params;
|
||||||
|
const { conditionFieldValue, parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 1. 기본 연쇄 관계 정보 조회
|
||||||
|
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||||
|
const relationParams: any[] = [relationCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationSql += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = await queryOne(relationSql, relationParams);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||||
|
let conditionSql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE relation_code = $1 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const conditionParams: any[] = [relationCode];
|
||||||
|
let conditionParamIndex = 2;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||||
|
conditionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditionSql += ` ORDER BY priority DESC`;
|
||||||
|
|
||||||
|
const conditions = await query(conditionSql, conditionParams);
|
||||||
|
|
||||||
|
// 3. 조건에 맞는 규칙 찾기
|
||||||
|
let matchedCondition: any = null;
|
||||||
|
|
||||||
|
if (conditionFieldValue) {
|
||||||
|
for (const cond of conditions) {
|
||||||
|
const isMatch = evaluateCondition(
|
||||||
|
conditionFieldValue as string,
|
||||||
|
cond.condition_operator,
|
||||||
|
cond.condition_value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matchedCondition = cond;
|
||||||
|
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 옵션 조회 쿼리 생성
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (기본 연쇄)
|
||||||
|
if (parentValue) {
|
||||||
|
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건부 필터 적용
|
||||||
|
if (matchedCondition) {
|
||||||
|
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
|
||||||
|
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||||
|
optionsParams.push(...filterValues);
|
||||||
|
optionsParamIndex += filterValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (relation.child_order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("조건부 필터링 옵션 조회", {
|
||||||
|
relationCode,
|
||||||
|
conditionFieldValue,
|
||||||
|
parentValue,
|
||||||
|
matchedCondition: matchedCondition?.condition_name,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
appliedCondition: matchedCondition
|
||||||
|
? {
|
||||||
|
conditionId: matchedCondition.condition_id,
|
||||||
|
conditionName: matchedCondition.condition_name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건 평가 함수
|
||||||
|
*/
|
||||||
|
function evaluateCondition(
|
||||||
|
actualValue: string,
|
||||||
|
operator: string,
|
||||||
|
expectedValue: string
|
||||||
|
): boolean {
|
||||||
|
const actual = actualValue.toLowerCase().trim();
|
||||||
|
const expected = expectedValue.toLowerCase().trim();
|
||||||
|
|
||||||
|
switch (operator.toUpperCase()) {
|
||||||
|
case "EQ":
|
||||||
|
case "=":
|
||||||
|
case "EQUALS":
|
||||||
|
return actual === expected;
|
||||||
|
|
||||||
|
case "NEQ":
|
||||||
|
case "!=":
|
||||||
|
case "<>":
|
||||||
|
case "NOT_EQUALS":
|
||||||
|
return actual !== expected;
|
||||||
|
|
||||||
|
case "CONTAINS":
|
||||||
|
case "LIKE":
|
||||||
|
return actual.includes(expected);
|
||||||
|
|
||||||
|
case "NOT_CONTAINS":
|
||||||
|
case "NOT_LIKE":
|
||||||
|
return !actual.includes(expected);
|
||||||
|
|
||||||
|
case "STARTS_WITH":
|
||||||
|
return actual.startsWith(expected);
|
||||||
|
|
||||||
|
case "ENDS_WITH":
|
||||||
|
return actual.endsWith(expected);
|
||||||
|
|
||||||
|
case "IN":
|
||||||
|
const inValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return inValues.includes(actual);
|
||||||
|
|
||||||
|
case "NOT_IN":
|
||||||
|
const notInValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return !notInValues.includes(actual);
|
||||||
|
|
||||||
|
case "GT":
|
||||||
|
case ">":
|
||||||
|
return parseFloat(actual) > parseFloat(expected);
|
||||||
|
|
||||||
|
case "GTE":
|
||||||
|
case ">=":
|
||||||
|
return parseFloat(actual) >= parseFloat(expected);
|
||||||
|
|
||||||
|
case "LT":
|
||||||
|
case "<":
|
||||||
|
return parseFloat(actual) < parseFloat(expected);
|
||||||
|
|
||||||
|
case "LTE":
|
||||||
|
case "<=":
|
||||||
|
return parseFloat(actual) <= parseFloat(expected);
|
||||||
|
|
||||||
|
case "IS_NULL":
|
||||||
|
case "NULL":
|
||||||
|
return actual === "" || actual === "null" || actual === "undefined";
|
||||||
|
|
||||||
|
case "IS_NOT_NULL":
|
||||||
|
case "NOT_NULL":
|
||||||
|
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,752 @@
|
|||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 컨트롤러
|
||||||
|
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, hierarchyType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT g.*,
|
||||||
|
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
|
||||||
|
FROM cascading_hierarchy_group g
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hierarchyType) {
|
||||||
|
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
|
||||||
|
params.push(hierarchyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 조회
|
||||||
|
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
levelSql += ` ORDER BY level_order`;
|
||||||
|
|
||||||
|
const levels = await query(levelSql, levelParams);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...group,
|
||||||
|
levels: levels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "HG";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
hierarchyType = "MULTI_TABLE",
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels = "Y",
|
||||||
|
// Self-reference 설정
|
||||||
|
selfRefTable,
|
||||||
|
selfRefIdColumn,
|
||||||
|
selfRefParentColumn,
|
||||||
|
selfRefValueColumn,
|
||||||
|
selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn,
|
||||||
|
// BOM 설정
|
||||||
|
bomTable,
|
||||||
|
bomParentColumn,
|
||||||
|
bomChildColumn,
|
||||||
|
bomItemTable,
|
||||||
|
bomItemIdColumn,
|
||||||
|
bomItemLabelColumn,
|
||||||
|
bomQtyColumn,
|
||||||
|
bomLevelColumn,
|
||||||
|
// 메시지
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
// 레벨 (MULTI_TABLE 타입인 경우)
|
||||||
|
levels = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !hierarchyType) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateHierarchyGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, description, hierarchy_type,
|
||||||
|
max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
|
||||||
|
empty_message, no_options_message, loading_message,
|
||||||
|
company_code, is_active, created_by, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const group = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
hierarchyType,
|
||||||
|
maxLevels || null,
|
||||||
|
isFixedLevels,
|
||||||
|
selfRefTable || null,
|
||||||
|
selfRefIdColumn || null,
|
||||||
|
selfRefParentColumn || null,
|
||||||
|
selfRefValueColumn || null,
|
||||||
|
selfRefLabelColumn || null,
|
||||||
|
selfRefLevelColumn || null,
|
||||||
|
selfRefOrderColumn || null,
|
||||||
|
bomTable || null,
|
||||||
|
bomParentColumn || null,
|
||||||
|
bomChildColumn || null,
|
||||||
|
bomItemTable || null,
|
||||||
|
bomItemIdColumn || null,
|
||||||
|
bomItemLabelColumn || null,
|
||||||
|
bomQtyColumn || null,
|
||||||
|
bomLevelColumn || null,
|
||||||
|
emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage || "로딩 중...",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 레벨 생성 (MULTI_TABLE 타입인 경우)
|
||||||
|
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
|
||||||
|
for (const level of levels) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
level.levelOrder,
|
||||||
|
level.levelName,
|
||||||
|
level.levelCode || null,
|
||||||
|
level.tableName,
|
||||||
|
level.valueColumn,
|
||||||
|
level.labelColumn,
|
||||||
|
level.parentKeyColumn || null,
|
||||||
|
level.filterColumn || null,
|
||||||
|
level.filterValue || null,
|
||||||
|
level.orderColumn || null,
|
||||||
|
level.orderDirection || "ASC",
|
||||||
|
level.placeholder || `${level.levelName} 선택`,
|
||||||
|
level.isRequired || "Y",
|
||||||
|
level.isSearchable || "N",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 생성되었습니다.",
|
||||||
|
data: group,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
max_levels = COALESCE($3, max_levels),
|
||||||
|
is_fixed_levels = COALESCE($4, is_fixed_levels),
|
||||||
|
empty_message = COALESCE($5, empty_message),
|
||||||
|
no_options_message = COALESCE($6, no_options_message),
|
||||||
|
loading_message = COALESCE($7, loading_message),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_by = $9,
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $10 AND company_code = $11
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
userId,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 수정", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 먼저 삭제
|
||||||
|
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteLevelsSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(deleteLevelsSql, levelParams);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteGroupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroupSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteGroupSql, groupParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 삭제", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 추가
|
||||||
|
*/
|
||||||
|
export const addLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection = "ASC",
|
||||||
|
placeholder,
|
||||||
|
isRequired = "Y",
|
||||||
|
isSearchable = "N",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 그룹 존재 확인
|
||||||
|
const groupCheck = await queryOne(
|
||||||
|
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
|
||||||
|
[groupCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupCheck) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
groupCode,
|
||||||
|
groupCheck.company_code,
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode || null,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn || null,
|
||||||
|
filterColumn || null,
|
||||||
|
filterValue || null,
|
||||||
|
orderColumn || null,
|
||||||
|
orderDirection,
|
||||||
|
placeholder || `${levelName} 선택`,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 추가되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 추가 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 추가에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 수정
|
||||||
|
*/
|
||||||
|
export const updateLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_level SET
|
||||||
|
level_name = COALESCE($1, level_name),
|
||||||
|
table_name = COALESCE($2, table_name),
|
||||||
|
value_column = COALESCE($3, value_column),
|
||||||
|
label_column = COALESCE($4, label_column),
|
||||||
|
parent_key_column = COALESCE($5, parent_key_column),
|
||||||
|
filter_column = COALESCE($6, filter_column),
|
||||||
|
filter_value = COALESCE($7, filter_value),
|
||||||
|
order_column = COALESCE($8, order_column),
|
||||||
|
order_direction = COALESCE($9, order_direction),
|
||||||
|
placeholder = COALESCE($10, placeholder),
|
||||||
|
is_required = COALESCE($11, is_required),
|
||||||
|
is_searchable = COALESCE($12, is_searchable),
|
||||||
|
is_active = COALESCE($13, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE level_id = $14
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
Number(levelId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 수정", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 삭제
|
||||||
|
*/
|
||||||
|
export const deleteLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING level_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 레벨 삭제", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레벨의 옵션 조회
|
||||||
|
*/
|
||||||
|
export const getLevelOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode, levelOrder } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 정보 조회
|
||||||
|
let levelSql = `
|
||||||
|
SELECT l.*, g.hierarchy_type
|
||||||
|
FROM cascading_hierarchy_level l
|
||||||
|
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
|
||||||
|
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const levelParams: any[] = [groupCode, Number(levelOrder)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND l.company_code = $3`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = await queryOne(levelSql, levelParams);
|
||||||
|
|
||||||
|
if (!level) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${level.value_column} as value,
|
||||||
|
${level.label_column} as label
|
||||||
|
FROM ${level.table_name}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (레벨 2 이상)
|
||||||
|
if (level.parent_key_column && parentValue) {
|
||||||
|
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고정 필터
|
||||||
|
if (level.filter_column && level.filter_value) {
|
||||||
|
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(level.filter_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[level.table_name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (level.order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${level.label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 옵션 조회", {
|
||||||
|
groupCode,
|
||||||
|
levelOrder,
|
||||||
|
parentValue,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
levelInfo: {
|
||||||
|
levelId: level.level_id,
|
||||||
|
levelName: level.level_name,
|
||||||
|
placeholder: level.placeholder,
|
||||||
|
isRequired: level.is_required,
|
||||||
|
isSearchable: level.is_searchable,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
||||||
|
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getExclusions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_mutual_exclusion
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY exclusion_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getExclusionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const params: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배제 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "EX";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType = "SAME_VALUE",
|
||||||
|
errorMessage = "동일한 값을 선택할 수 없습니다",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배제 코드 자동 생성
|
||||||
|
const exclusionCode = await generateExclusionCode(companyCode);
|
||||||
|
|
||||||
|
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
|
||||||
|
const existingCheck = await queryOne(
|
||||||
|
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
|
||||||
|
[exclusionCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 배제 코드입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
exclusion_type, error_message,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
exclusionCode,
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn || null,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_mutual_exclusion SET
|
||||||
|
exclusion_name = COALESCE($1, exclusion_name),
|
||||||
|
field_names = COALESCE($2, field_names),
|
||||||
|
source_table = COALESCE($3, source_table),
|
||||||
|
value_column = COALESCE($4, value_column),
|
||||||
|
label_column = COALESCE($5, label_column),
|
||||||
|
exclusion_type = COALESCE($6, exclusion_type),
|
||||||
|
error_message = COALESCE($7, error_message),
|
||||||
|
is_active = COALESCE($8, is_active)
|
||||||
|
WHERE exclusion_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
Number(exclusionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING exclusion_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 검증
|
||||||
|
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||||
|
*/
|
||||||
|
export const validateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드명 파싱
|
||||||
|
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
|
||||||
|
|
||||||
|
// 필드 값 수집
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (fieldValues[field]) {
|
||||||
|
values.push(fieldValues[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
let isValid = true;
|
||||||
|
let errorMessage = null;
|
||||||
|
let conflictingFields: string[] = [];
|
||||||
|
|
||||||
|
if (exclusion.exclusion_type === "SAME_VALUE") {
|
||||||
|
// 같은 값이 있는지 확인
|
||||||
|
const uniqueValues = new Set(values);
|
||||||
|
if (uniqueValues.size !== values.length) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = exclusion.error_message;
|
||||||
|
|
||||||
|
// 충돌하는 필드 찾기
|
||||||
|
const valueCounts: Record<string, string[]> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const val = fieldValues[field];
|
||||||
|
if (val) {
|
||||||
|
if (!valueCounts[val]) {
|
||||||
|
valueCounts[val] = [];
|
||||||
|
}
|
||||||
|
valueCounts[val].push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, fieldList] of Object.entries(valueCounts)) {
|
||||||
|
if (fieldList.length > 1) {
|
||||||
|
conflictingFields = fieldList;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 검증", {
|
||||||
|
exclusionCode,
|
||||||
|
isValid,
|
||||||
|
fieldValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isValid,
|
||||||
|
errorMessage: isValid ? null : errorMessage,
|
||||||
|
conflictingFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 검증 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 검증에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드에 대한 배제 옵션 조회
|
||||||
|
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getExcludedOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
const labelColumn = exclusion.label_column || exclusion.value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${exclusion.value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${exclusion.source_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[exclusion.source_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 선택된 값 제외
|
||||||
|
if (selectedValues) {
|
||||||
|
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
|
||||||
|
if (excludeValues.length > 0) {
|
||||||
|
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||||
|
optionsParams.push(...excludeValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("상호 배제 옵션 조회", {
|
||||||
|
exclusionCode,
|
||||||
|
currentField,
|
||||||
|
excludedCount: (selectedValues as string)?.split(",").length || 0,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAutoFillGroups,
|
||||||
|
getAutoFillGroupDetail,
|
||||||
|
createAutoFillGroup,
|
||||||
|
updateAutoFillGroup,
|
||||||
|
deleteAutoFillGroup,
|
||||||
|
getAutoFillMasterOptions,
|
||||||
|
getAutoFillData,
|
||||||
|
} from "../controllers/cascadingAutoFillController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/groups", getAutoFillGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (매핑 포함)
|
||||||
|
router.get("/groups/:groupCode", getAutoFillGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/groups", createAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/groups/:groupCode", updateAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/groups/:groupCode", deleteAutoFillGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 마스터 옵션 목록 조회
|
||||||
|
router.get("/options/:groupCode", getAutoFillMasterOptions);
|
||||||
|
|
||||||
|
// 자동 입력 데이터 조회
|
||||||
|
router.get("/data/:groupCode", getAutoFillData);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getConditions,
|
||||||
|
getConditionDetail,
|
||||||
|
createCondition,
|
||||||
|
updateCondition,
|
||||||
|
deleteCondition,
|
||||||
|
getFilteredOptions,
|
||||||
|
} from "../controllers/cascadingConditionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getConditions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:conditionId", getConditionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createCondition);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:conditionId", updateCondition);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:conditionId", deleteCondition);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 조건에 따른 필터링된 옵션 조회
|
||||||
|
router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getHierarchyGroups,
|
||||||
|
getHierarchyGroupDetail,
|
||||||
|
createHierarchyGroup,
|
||||||
|
updateHierarchyGroup,
|
||||||
|
deleteHierarchyGroup,
|
||||||
|
addLevel,
|
||||||
|
updateLevel,
|
||||||
|
deleteLevel,
|
||||||
|
getLevelOptions,
|
||||||
|
} from "../controllers/cascadingHierarchyController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/", getHierarchyGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (레벨 포함)
|
||||||
|
router.get("/:groupCode", getHierarchyGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/", createHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/:groupCode", updateHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/:groupCode", deleteHierarchyGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 레벨 추가
|
||||||
|
router.post("/:groupCode/levels", addLevel);
|
||||||
|
|
||||||
|
// 레벨 수정
|
||||||
|
router.put("/levels/:levelId", updateLevel);
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
router.delete("/levels/:levelId", deleteLevel);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 특정 레벨의 옵션 조회
|
||||||
|
router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getExclusions,
|
||||||
|
getExclusionDetail,
|
||||||
|
createExclusion,
|
||||||
|
updateExclusion,
|
||||||
|
deleteExclusion,
|
||||||
|
validateExclusion,
|
||||||
|
getExcludedOptions,
|
||||||
|
} from "../controllers/cascadingMutualExclusionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getExclusions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:exclusionId", getExclusionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createExclusion);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:exclusionId", updateExclusion);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:exclusionId", deleteExclusion);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 및 옵션 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
router.post("/validate/:exclusionCode", validateExclusion);
|
||||||
|
|
||||||
|
// 배제된 옵션 조회
|
||||||
|
router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
# 레벨 기반 연쇄 드롭다운 시스템 설계
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축
|
||||||
|
|
||||||
|
### 1.2 지원하는 계층 유형
|
||||||
|
|
||||||
|
| 유형 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 |
|
||||||
|
| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 |
|
||||||
|
| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 |
|
||||||
|
| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 데이터베이스 설계
|
||||||
|
|
||||||
|
### 2.1 테이블 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_group │ ← 계층 그룹 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (PK) │
|
||||||
|
│ group_name │
|
||||||
|
│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE
|
||||||
|
│ max_levels │
|
||||||
|
│ is_fixed_levels │
|
||||||
|
│ self_ref_* (자기참조 설정) │
|
||||||
|
│ bom_* (BOM 설정) │
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (MULTI_TABLE 유형만)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (FK) │
|
||||||
|
│ level_order │ ← 1, 2, 3...
|
||||||
|
│ level_name │
|
||||||
|
│ table_name │
|
||||||
|
│ value_column │
|
||||||
|
│ label_column │
|
||||||
|
│ parent_key_column │ ← 부모 테이블 참조 컬럼
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 기존 시스템과의 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_relation │ ← 기존 2단계 관계 (유지)
|
||||||
|
│ (2단계 전용) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 호환성 뷰
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 계층 유형별 상세 설계
|
||||||
|
|
||||||
|
### 3.1 MULTI_TABLE (다중 테이블 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 국가 → 시/도 → 구/군 → 동
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
country_info province_info city_info district_info
|
||||||
|
├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK)
|
||||||
|
├─ country_name ├─ province_name ├─ city_name ├─ district_name
|
||||||
|
├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK)
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
) VALUES (
|
||||||
|
'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 레벨 정의
|
||||||
|
INSERT INTO cascading_hierarchy_level VALUES
|
||||||
|
(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL),
|
||||||
|
(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'),
|
||||||
|
(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'),
|
||||||
|
(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1
|
||||||
|
→ [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR
|
||||||
|
→ [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL
|
||||||
|
→ [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SELF_REFERENCE (자기참조 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류)
|
||||||
|
|
||||||
|
**테이블 구조** (code_info 활용):
|
||||||
|
```
|
||||||
|
code_info
|
||||||
|
├─ code_category = 'PRODUCT_CATEGORY'
|
||||||
|
├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED'
|
||||||
|
├─ code_name = '전자제품', 'TV', 'LED TV'
|
||||||
|
├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조
|
||||||
|
├─ level = 1, 2, 3
|
||||||
|
├─ sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column,
|
||||||
|
self_ref_filter_column, self_ref_filter_value,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3,
|
||||||
|
'code_info', 'code_value', 'parent_code',
|
||||||
|
'code_value', 'code_name', 'level',
|
||||||
|
'code_category', 'PRODUCT_CATEGORY',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1
|
||||||
|
→ WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC
|
||||||
|
→ WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV
|
||||||
|
→ WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 BOM (Bill of Materials)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 BOM 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
klbom_tbl (BOM 관계) item_info (품목 마스터)
|
||||||
|
├─ id (자식 품목) ├─ item_code (PK)
|
||||||
|
├─ pid (부모 품목) ├─ item_name
|
||||||
|
├─ qty (수량) ├─ item_spec
|
||||||
|
├─ aylevel (레벨) ├─ unit
|
||||||
|
├─ bom_report_objid
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column,
|
||||||
|
bom_qty_column, bom_level_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N',
|
||||||
|
'klbom_tbl', 'pid', 'id',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'qty', 'aylevel',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots
|
||||||
|
→ WHERE pid IS NULL OR pid = ''
|
||||||
|
→ [{ value: 'PROD001', label: '완제품 A', level: 1 }]
|
||||||
|
|
||||||
|
2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001
|
||||||
|
→ WHERE pid = 'PROD001'
|
||||||
|
→ [
|
||||||
|
{ value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 },
|
||||||
|
{ value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001
|
||||||
|
→ WHERE pid = 'ASSY001'
|
||||||
|
→ [
|
||||||
|
{ value: 'PART001', label: '부품 A', qty: 4, level: 3 },
|
||||||
|
{ value: 'PART002', label: '부품 B', qty: 2, level: 3 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**BOM 전용 응답 형식**:
|
||||||
|
```typescript
|
||||||
|
interface BomOption {
|
||||||
|
value: string; // 품목 코드
|
||||||
|
label: string; // 품목명
|
||||||
|
qty: number; // 수량
|
||||||
|
level: number; // BOM 레벨
|
||||||
|
hasChildren: boolean; // 하위 품목 존재 여부
|
||||||
|
spec?: string; // 규격 (선택)
|
||||||
|
unit?: string; // 단위 (선택)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 TREE (무한 깊이 트리)
|
||||||
|
|
||||||
|
**사용 사례**: 조직도, 메뉴 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
dept_info
|
||||||
|
├─ dept_code (PK)
|
||||||
|
├─ dept_name
|
||||||
|
├─ parent_dept_code ← 자기참조 (무한 깊이)
|
||||||
|
├─ sort_order
|
||||||
|
├─ is_active
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_order_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ORG_CHART', '조직도', 'TREE', NULL, 'N',
|
||||||
|
'dept_info', 'dept_code', 'parent_dept_code',
|
||||||
|
'dept_code', 'dept_name', 'sort_order',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름** (BOM과 유사):
|
||||||
|
```
|
||||||
|
1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots
|
||||||
|
→ WHERE parent_dept_code IS NULL
|
||||||
|
→ [{ value: 'HQ', label: '본사', hasChildren: true }]
|
||||||
|
|
||||||
|
2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ
|
||||||
|
→ WHERE parent_dept_code = 'HQ'
|
||||||
|
→ [
|
||||||
|
{ value: 'DIV1', label: '사업부1', hasChildren: true },
|
||||||
|
{ value: 'DIV2', label: '사업부2', hasChildren: true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계
|
||||||
|
|
||||||
|
### 4.1 계층 그룹 관리 API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups # 그룹 목록
|
||||||
|
POST /api/cascading-hierarchy/groups # 그룹 생성
|
||||||
|
GET /api/cascading-hierarchy/groups/:code # 그룹 상세
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code # 그룹 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 레벨 관리 API (MULTI_TABLE용)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록
|
||||||
|
POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 옵션 조회 API
|
||||||
|
|
||||||
|
```
|
||||||
|
# MULTI_TABLE / SELF_REFERENCE
|
||||||
|
GET /api/cascading-hierarchy/options/:groupCode/:level
|
||||||
|
?parentValue=xxx # 부모 값 (레벨 2 이상)
|
||||||
|
&companyCode=xxx # 회사 코드 (선택)
|
||||||
|
|
||||||
|
# BOM / TREE
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드
|
||||||
|
?parentValue=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회
|
||||||
|
?value=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/search # 검색
|
||||||
|
?keyword=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 컴포넌트 설계
|
||||||
|
|
||||||
|
### 5.1 CascadingHierarchyDropdown
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyDropdownProps {
|
||||||
|
groupCode: string; // 계층 그룹 코드
|
||||||
|
level: number; // 현재 레벨 (1, 2, 3...)
|
||||||
|
parentValue?: string; // 부모 값 (레벨 2 이상)
|
||||||
|
value?: string; // 선택된 값
|
||||||
|
onChange: (value: string, option: HierarchyOption) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시 (지역 계층)
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={1} onChange={setCountry} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={2} parentValue={country} onChange={setProvince} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={3} parentValue={province} onChange={setCity} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 CascadingHierarchyGroup (자동 연결)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyGroupProps {
|
||||||
|
groupCode: string;
|
||||||
|
values: Record<number, string>; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' }
|
||||||
|
onChange: (level: number, value: string) => void;
|
||||||
|
layout?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<CascadingHierarchyGroup
|
||||||
|
groupCode="REGION_HIERARCHY"
|
||||||
|
values={regionValues}
|
||||||
|
onChange={(level, value) => {
|
||||||
|
setRegionValues(prev => ({ ...prev, [level]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 BomTreeSelect (BOM 전용)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BomTreeSelectProps {
|
||||||
|
groupCode: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string, path: BomOption[]) => void;
|
||||||
|
showQty?: boolean; // 수량 표시
|
||||||
|
showLevel?: boolean; // 레벨 표시
|
||||||
|
maxDepth?: number; // 최대 깊이 제한
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<BomTreeSelect
|
||||||
|
groupCode="PRODUCT_BOM"
|
||||||
|
value={selectedPart}
|
||||||
|
onChange={(value, path) => {
|
||||||
|
setSelectedPart(value);
|
||||||
|
console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품]
|
||||||
|
}}
|
||||||
|
showQty
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 화면관리 시스템 통합
|
||||||
|
|
||||||
|
### 6.1 컴포넌트 설정 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SelectBasicConfig {
|
||||||
|
// 기존 설정
|
||||||
|
cascadingEnabled?: boolean;
|
||||||
|
cascadingRelationCode?: string; // 기존 2단계 관계
|
||||||
|
cascadingRole?: 'parent' | 'child';
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
|
// 🆕 레벨 기반 계층 설정
|
||||||
|
hierarchyEnabled?: boolean;
|
||||||
|
hierarchyGroupCode?: string; // 계층 그룹 코드
|
||||||
|
hierarchyLevel?: number; // 이 컴포넌트의 레벨
|
||||||
|
hierarchyParentField?: string; // 부모 레벨 필드명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 설정 UI 확장
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 설정 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ○ 2단계 관계 (기존) │
|
||||||
|
│ └─ 관계 선택: [창고-위치 ▼] │
|
||||||
|
│ └─ 역할: [부모] [자식] │
|
||||||
|
│ │
|
||||||
|
│ ● 다단계 계층 (신규) │
|
||||||
|
│ └─ 계층 그룹: [지역 계층 ▼] │
|
||||||
|
│ └─ 레벨: [2 - 시/도 ▼] │
|
||||||
|
│ └─ 부모 필드: [country_code] (자동감지) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 기반 구축
|
||||||
|
1. ✅ 기존 2단계 연쇄 드롭다운 완성
|
||||||
|
2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql)
|
||||||
|
3. 📋 백엔드 API 구현 (계층 그룹 CRUD)
|
||||||
|
|
||||||
|
### Phase 2: MULTI_TABLE 지원
|
||||||
|
1. 📋 레벨 관리 API
|
||||||
|
2. 📋 옵션 조회 API
|
||||||
|
3. 📋 프론트엔드 컴포넌트
|
||||||
|
|
||||||
|
### Phase 3: SELF_REFERENCE 지원
|
||||||
|
1. 📋 자기참조 쿼리 로직
|
||||||
|
2. 📋 code_info 기반 카테고리 계층
|
||||||
|
|
||||||
|
### Phase 4: BOM/TREE 지원
|
||||||
|
1. 📋 BOM 전용 API
|
||||||
|
2. 📋 트리 컴포넌트
|
||||||
|
3. 📋 무한 깊이 지원
|
||||||
|
|
||||||
|
### Phase 5: 화면관리 통합
|
||||||
|
1. 📋 설정 UI 확장
|
||||||
|
2. 📋 자동 연결 기능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 성능 고려사항
|
||||||
|
|
||||||
|
### 8.1 쿼리 최적화
|
||||||
|
- 인덱스: `(group_code, company_code, level_order)`
|
||||||
|
- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱
|
||||||
|
- Lazy Loading: 하위 레벨은 필요 시에만 로드
|
||||||
|
|
||||||
|
### 8.2 BOM 재귀 쿼리
|
||||||
|
```sql
|
||||||
|
-- PostgreSQL WITH RECURSIVE 활용
|
||||||
|
WITH RECURSIVE bom_tree AS (
|
||||||
|
-- 루트 노드
|
||||||
|
SELECT id, pid, qty, 1 AS level
|
||||||
|
FROM klbom_tbl
|
||||||
|
WHERE pid IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 하위 노드
|
||||||
|
SELECT b.id, b.pid, b.qty, t.level + 1
|
||||||
|
FROM klbom_tbl b
|
||||||
|
JOIN bom_tree t ON b.pid = t.id
|
||||||
|
WHERE t.level < 10 -- 최대 깊이 제한
|
||||||
|
)
|
||||||
|
SELECT * FROM bom_tree;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 트리 최적화 전략
|
||||||
|
- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1`
|
||||||
|
- Nested Set: left/right 값으로 범위 쿼리
|
||||||
|
- Closure Table: 별도 관계 테이블
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 추가 연쇄 패턴
|
||||||
|
|
||||||
|
### 9.1 조건부 연쇄 (Conditional Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시
|
||||||
|
|
||||||
|
```
|
||||||
|
입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시
|
||||||
|
입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_condition`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, company_code
|
||||||
|
) VALUES
|
||||||
|
('WAREHOUSE_LOCATION', '구매입고 창고',
|
||||||
|
'inbound_type', 'EQ', 'PURCHASE',
|
||||||
|
'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 다중 부모 연쇄 (Multi-Parent Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링
|
||||||
|
|
||||||
|
```
|
||||||
|
회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 관계 정의
|
||||||
|
INSERT INTO cascading_multi_parent (
|
||||||
|
relation_code, relation_name,
|
||||||
|
child_table, child_value_column, child_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'COMPANY_DIVISION_DEPT', '회사-사업부-부서',
|
||||||
|
'dept_info', 'dept_code', 'dept_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 부모 소스 정의
|
||||||
|
INSERT INTO cascading_multi_parent_source (
|
||||||
|
relation_code, company_code, parent_order, parent_name,
|
||||||
|
parent_table, parent_value_column, child_filter_column
|
||||||
|
) VALUES
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'),
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 자동 입력 그룹 (Auto-Fill Group)
|
||||||
|
|
||||||
|
**사용 사례**: 마스터 선택 시 여러 필드 자동 입력
|
||||||
|
|
||||||
|
```
|
||||||
|
고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name,
|
||||||
|
master_table, master_value_column, master_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력',
|
||||||
|
'customer_info', 'customer_code', 'customer_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 필드 매핑
|
||||||
|
INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label
|
||||||
|
) VALUES
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.4 상호 배제 (Mutual Exclusion)
|
||||||
|
|
||||||
|
**사용 사례**: 같은 값 선택 불가
|
||||||
|
|
||||||
|
```
|
||||||
|
출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외)
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_mutual_exclusion`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
error_message, company_code
|
||||||
|
) VALUES (
|
||||||
|
'WAREHOUSE_TRANSFER', '창고간 이동',
|
||||||
|
'from_warehouse_code,to_warehouse_code',
|
||||||
|
'warehouse_info', 'warehouse_code', 'warehouse_name',
|
||||||
|
'출발 창고와 도착 창고는 같을 수 없습니다',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.5 역방향 조회 (Reverse Lookup)
|
||||||
|
|
||||||
|
**사용 사례**: 자식에서 부모 방향으로 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_reverse_lookup`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_reverse_lookup (
|
||||||
|
lookup_code, lookup_name,
|
||||||
|
source_table, source_value_column, source_label_column,
|
||||||
|
target_table, target_value_column, target_label_column, target_link_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ITEM_USED_IN_BOM', '품목 사용처 BOM',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'klbom_tbl', 'pid', 'ayupgname', 'id',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 전체 테이블 구조 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 시스템 구조 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [기존 - 2단계] │
|
||||||
|
│ cascading_relation ─────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다단계 계층] │
|
||||||
|
│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │
|
||||||
|
│ │ (MULTI_TABLE용) │
|
||||||
|
│ │ │
|
||||||
|
│ [신규 - 조건부] │
|
||||||
|
│ cascading_condition ────────┴── 조건에 따른 필터링 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다중 부모] │
|
||||||
|
│ cascading_multi_parent ─────┬── cascading_multi_parent_source │
|
||||||
|
│ │ (여러 부모 조합) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 자동 입력] │
|
||||||
|
│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │
|
||||||
|
│ │ (마스터→다중 필드) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 상호 배제] │
|
||||||
|
│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 역방향] │
|
||||||
|
│ cascading_reverse_lookup ───┴── 자식→부모 조회 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 마이그레이션 가이드
|
||||||
|
|
||||||
|
### 11.1 기존 데이터 마이그레이션
|
||||||
|
```sql
|
||||||
|
-- 기존 cascading_relation → cascading_hierarchy_group 변환
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'LEGACY_' || relation_code,
|
||||||
|
relation_name,
|
||||||
|
'MULTI_TABLE',
|
||||||
|
2,
|
||||||
|
company_code
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE is_active = 'Y';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 호환성 유지
|
||||||
|
- 기존 `cascading_relation` 테이블 유지
|
||||||
|
- 기존 API 엔드포인트 유지
|
||||||
|
- 점진적 마이그레이션 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 우선순위 (업데이트)
|
||||||
|
|
||||||
|
| Phase | 기능 | 복잡도 | 우선순위 |
|
||||||
|
|-------|------|--------|----------|
|
||||||
|
| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 |
|
||||||
|
| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 |
|
||||||
|
| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 |
|
||||||
|
| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 |
|
||||||
|
| 5 | 조건부 연쇄 | 중 | 중간 |
|
||||||
|
| 6 | 상호 배제 | 낮음 | 중간 |
|
||||||
|
| 7 | 다중 부모 연쇄 | 높음 | 낮음 |
|
||||||
|
| 8 | BOM/TREE 구조 | 높음 | 낮음 |
|
||||||
|
| 9 | 역방향 조회 | 중 | 낮음 |
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default function AutoFillRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/admin/cascading-management?tab=autofill");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||||
|
|
||||||
|
// 탭별 컴포넌트
|
||||||
|
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||||
|
import AutoFillTab from "./tabs/AutoFillTab";
|
||||||
|
import HierarchyTab from "./tabs/HierarchyTab";
|
||||||
|
import ConditionTab from "./tabs/ConditionTab";
|
||||||
|
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||||
|
|
||||||
|
export default function CascadingManagementPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState("relations");
|
||||||
|
|
||||||
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = searchParams.get("tab");
|
||||||
|
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 탭 변경 시 URL 업데이트
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", value);
|
||||||
|
router.replace(url.pathname + url.search);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
|
<TabsTrigger value="relations" className="gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||||
|
<span className="sm:hidden">연쇄</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="hierarchy" className="gap-2">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">다단계 계층</span>
|
||||||
|
<span className="sm:hidden">계층</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="condition" className="gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">조건부 필터</span>
|
||||||
|
<span className="sm:hidden">조건</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="autofill" className="gap-2">
|
||||||
|
<FormInput className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">자동 입력</span>
|
||||||
|
<span className="sm:hidden">자동</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="exclusion" className="gap-2">
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">상호 배제</span>
|
||||||
|
<span className="sm:hidden">배제</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<TabsContent value="relations">
|
||||||
|
<CascadingRelationsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="hierarchy">
|
||||||
|
<HierarchyTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="condition">
|
||||||
|
<ConditionTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="autofill">
|
||||||
|
<AutoFillTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="exclusion">
|
||||||
|
<MutualExclusionTab />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ArrowRight,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableColumn {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoFillTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||||
|
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터
|
||||||
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setGroups(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("그룹 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 컬럼 로드
|
||||||
|
const loadColumns = useCallback(async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setMasterColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setMasterColumns(
|
||||||
|
response.data.columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||||
|
dataType: col.dataType || col.data_type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
setMasterColumns([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadGroups, loadTableList]);
|
||||||
|
|
||||||
|
// 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.masterTable) {
|
||||||
|
loadColumns(formData.masterTable);
|
||||||
|
}
|
||||||
|
}, [formData.masterTable, loadColumns]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
setMappings([]);
|
||||||
|
setMasterColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
|
||||||
|
// 상세 정보 로드
|
||||||
|
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detail = detailResponse.data;
|
||||||
|
|
||||||
|
// 컬럼 먼저 로드
|
||||||
|
if (detail.masterTable) {
|
||||||
|
await loadColumns(detail.masterTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
groupCode: detail.groupCode,
|
||||||
|
groupName: detail.groupName,
|
||||||
|
description: detail.description || "",
|
||||||
|
masterTable: detail.masterTable,
|
||||||
|
masterValueColumn: detail.masterValueColumn,
|
||||||
|
masterLabelColumn: detail.masterLabelColumn || "",
|
||||||
|
isActive: detail.isActive || "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||||
|
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||||
|
sourceColumn: m.source_column || m.sourceColumn,
|
||||||
|
targetField: m.target_field || m.targetField,
|
||||||
|
targetLabel: m.target_label || m.targetLabel || "",
|
||||||
|
isEditable: m.is_editable || m.isEditable || "Y",
|
||||||
|
isRequired: m.is_required || m.isRequired || "N",
|
||||||
|
defaultValue: m.default_value || m.defaultValue || "",
|
||||||
|
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||||
|
}));
|
||||||
|
setMappings(convertedMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveData = {
|
||||||
|
...formData,
|
||||||
|
mappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 추가
|
||||||
|
const handleAddMapping = () => {
|
||||||
|
setMappings([
|
||||||
|
...mappings,
|
||||||
|
{
|
||||||
|
sourceColumn: "",
|
||||||
|
targetField: "",
|
||||||
|
targetLabel: "",
|
||||||
|
isEditable: "Y",
|
||||||
|
isRequired: "N",
|
||||||
|
defaultValue: "",
|
||||||
|
sortOrder: mappings.length + 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 삭제
|
||||||
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
setMappings(mappings.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 수정
|
||||||
|
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 및 액션 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>자동 입력 그룹</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>그룹 코드</TableHead>
|
||||||
|
<TableHead>그룹명</TableHead>
|
||||||
|
<TableHead>마스터 테이블</TableHead>
|
||||||
|
<TableHead>매핑 수</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<TableRow key={group.groupCode}>
|
||||||
|
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 고객사 정보 자동입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={formData.isActive === "Y"}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||||
|
/>
|
||||||
|
<Label>활성화</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 마스터 테이블 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>마스터 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.masterTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||||
|
formData.masterTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
masterTable: table.tableName,
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
});
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
|
{table.displayName && table.displayName !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterValueColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterLabelColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||||
|
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
|
|
||||||
|
{/* 소스 컬럼 */}
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceColumn}
|
||||||
|
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
|
||||||
|
{/* 타겟 필드 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetField}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||||
|
placeholder="타겟 필드명 (예: contact_name)"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타겟 라벨 */}
|
||||||
|
<div className="w-28">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetLabel || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`editable-${index}`}
|
||||||
|
checked={mapping.isEditable === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||||
|
수정
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`required-${index}`}
|
||||||
|
checked={mapping.isRequired === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||||
|
필수
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleRemoveMapping(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CascadingRelationsTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||||||
|
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||||||
|
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 고급 설정 토글
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
||||||
|
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 조회
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName || t.name,
|
||||||
|
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컬럼 목록 조회 (수정됨)
|
||||||
|
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
setParentColumns([]);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// getColumnList 사용 (getTableColumns가 아님)
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 응답 구조: { data: { columns: [...] } }
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||||||
|
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
// 자동 추천
|
||||||
|
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||||||
|
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 컬럼 선택 (패턴 매칭)
|
||||||
|
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||||||
|
// 이미 값이 있으면 스킵
|
||||||
|
if (formData[field]) return;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||||||
|
if (found) {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRelations();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadRelations, loadTableList]);
|
||||||
|
|
||||||
|
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.parentTable) {
|
||||||
|
loadColumns(formData.parentTable, "parent");
|
||||||
|
} else {
|
||||||
|
setParentColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.parentTable, editingRelation]);
|
||||||
|
|
||||||
|
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.childTable) {
|
||||||
|
loadColumns(formData.childTable, "child");
|
||||||
|
} else {
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.childTable, editingRelation]);
|
||||||
|
|
||||||
|
// 관계 코드 자동 생성
|
||||||
|
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
return `${parent}_${child}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계명 자동 생성
|
||||||
|
const generateRelationName = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||||||
|
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||||||
|
const parentName = parentInfo?.tableLabel || parentTable;
|
||||||
|
const childName = childInfo?.tableLabel || childTable;
|
||||||
|
return `${parentName}-${childName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (신규)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingRelation(null);
|
||||||
|
setFormData({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
setParentColumns([]);
|
||||||
|
setChildColumns([]);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||||||
|
setEditingRelation(relation);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
|
||||||
|
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||||||
|
const loadPromises: Promise<void>[] = [];
|
||||||
|
if (relation.parent_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||||||
|
}
|
||||||
|
if (relation.child_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 로드 완료 대기
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
|
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||||||
|
setFormData({
|
||||||
|
relationCode: relation.relation_code,
|
||||||
|
relationName: relation.relation_name,
|
||||||
|
description: relation.description || "",
|
||||||
|
parentTable: relation.parent_table,
|
||||||
|
parentValueColumn: relation.parent_value_column,
|
||||||
|
parentLabelColumn: relation.parent_label_column || "",
|
||||||
|
childTable: relation.child_table,
|
||||||
|
childFilterColumn: relation.child_filter_column,
|
||||||
|
childValueColumn: relation.child_value_column,
|
||||||
|
childLabelColumn: relation.child_label_column,
|
||||||
|
childOrderColumn: relation.child_order_column || "",
|
||||||
|
childOrderDirection: relation.child_order_direction || "ASC",
|
||||||
|
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: relation.loading_message || "로딩 중...",
|
||||||
|
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 테이블 선택 시 자동 설정
|
||||||
|
const handleParentTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.parentTable;
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parentTable: value,
|
||||||
|
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||||||
|
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "parent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자식 테이블 선택 시 자동 설정
|
||||||
|
const handleChildTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.childTable;
|
||||||
|
|
||||||
|
const newFormData = {
|
||||||
|
...formData,
|
||||||
|
childTable: value,
|
||||||
|
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||||||
|
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||||||
|
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||||||
|
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||||||
|
if (!editingRelation) {
|
||||||
|
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||||||
|
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(newFormData);
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "child");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!formData.parentTable || !formData.parentValueColumn) {
|
||||||
|
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!formData.childTable ||
|
||||||
|
!formData.childFilterColumn ||
|
||||||
|
!formData.childValueColumn ||
|
||||||
|
!formData.childLabelColumn
|
||||||
|
) {
|
||||||
|
toast.error("자식 테이블 설정을 완료해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (비어있으면)
|
||||||
|
const finalData = { ...formData };
|
||||||
|
if (!finalData.relationCode) {
|
||||||
|
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
if (!finalData.relationName) {
|
||||||
|
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingRelation) {
|
||||||
|
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingRelationApi.create(finalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = async (relation: CascadingRelation) => {
|
||||||
|
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("연쇄 관계가 삭제되었습니다.");
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 목록
|
||||||
|
const filteredRelations = relations.filter(
|
||||||
|
(r) =>
|
||||||
|
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 셀렉트 렌더링 헬퍼
|
||||||
|
const renderColumnSelect = (
|
||||||
|
value: string,
|
||||||
|
onChange: (v: string) => void,
|
||||||
|
columns: ColumnInfo[],
|
||||||
|
loading: boolean,
|
||||||
|
placeholder: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
) => (
|
||||||
|
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span className="text-xs">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||||||
|
) : (
|
||||||
|
columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{col.columnLabel}</span>
|
||||||
|
{col.columnLabel !== col.columnName && (
|
||||||
|
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
2단계 연쇄 관계
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>부모-자식 관계로 연결된 드롭다운을 정의합니다. (예: 창고 → 위치)</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>관계명</TableHead>
|
||||||
|
<TableHead>연결</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="w-[100px]">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredRelations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRelations.map((relation) => (
|
||||||
|
<TableRow key={relation.relation_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{relation.relation_name}</div>
|
||||||
|
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||||||
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||||||
|
{relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||||||
|
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 - 간소화된 UI */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||||||
|
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1: 부모 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={parentTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{loadingTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: formData.parentTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
||||||
|
formData.parentTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleParentTableChange(table.tableName);
|
||||||
|
setParentTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.parentValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||||||
|
parentColumns,
|
||||||
|
loadingParentColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.parentTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: 자식 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={childTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
disabled={!formData.parentTable}
|
||||||
|
>
|
||||||
|
{formData.childTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
||||||
|
formData.childTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChildTableChange(table.tableName);
|
||||||
|
setChildTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childFilterColumn,
|
||||||
|
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childLabelColumn,
|
||||||
|
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관계 정보 (자동 생성) */}
|
||||||
|
{formData.parentTable && formData.childTable && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계 코드</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={!!editingRelation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 고급 설정 토글 */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span>고급 설정</span>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 관계에 대한 설명..."
|
||||||
|
rows={2}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">상위 미선택 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.emptyParentMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">옵션 없음 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.noOptionsMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.clearOnParentChange}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : editingRelation ? (
|
||||||
|
"수정"
|
||||||
|
) : (
|
||||||
|
"생성"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
cascadingConditionApi,
|
||||||
|
CascadingCondition,
|
||||||
|
CONDITION_OPERATORS,
|
||||||
|
} from "@/lib/api/cascadingCondition";
|
||||||
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
|
export default function ConditionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||||
|
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||||
|
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadConditions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setConditions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("조건 목록 로드 실패:", error);
|
||||||
|
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConditions();
|
||||||
|
loadRelations();
|
||||||
|
}, [loadConditions, loadRelations]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredConditions = conditions.filter(
|
||||||
|
(c) =>
|
||||||
|
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingCondition(null);
|
||||||
|
setFormData({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||||
|
setEditingCondition(condition);
|
||||||
|
setFormData({
|
||||||
|
relationType: condition.relationType || "RELATION",
|
||||||
|
relationCode: condition.relationCode,
|
||||||
|
conditionName: condition.conditionName,
|
||||||
|
conditionField: condition.conditionField,
|
||||||
|
conditionOperator: condition.conditionOperator,
|
||||||
|
conditionValue: condition.conditionValue,
|
||||||
|
filterColumn: condition.filterColumn,
|
||||||
|
filterValues: condition.filterValues,
|
||||||
|
priority: condition.priority || 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (conditionId: number) => {
|
||||||
|
setDeletingConditionId(conditionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingConditionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingConditionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||||
|
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingCondition) {
|
||||||
|
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingConditionApi.create(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연산자 라벨 찾기
|
||||||
|
const getOperatorLabel = (operator: string) => {
|
||||||
|
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadConditions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
조건부 필터 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredConditions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 품목 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"상태" 필드가 "활성"일 때만 "품목" 드롭다운에 활성 품목만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 옵션 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"유형" 필드가 "입고"일 때 "창고" 드롭다운에 입고 가능 창고만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>연쇄 관계</TableHead>
|
||||||
|
<TableHead>조건명</TableHead>
|
||||||
|
<TableHead>조건</TableHead>
|
||||||
|
<TableHead>필터</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredConditions.map((condition) => (
|
||||||
|
<TableRow key={condition.conditionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||||
|
<span className="mx-1 text-blue-600">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||||
|
<span className="font-medium">{condition.conditionValue}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||||
|
<span className="mx-1">=</span>
|
||||||
|
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 연쇄 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>연쇄 관계 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.relationCode}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="연쇄 관계 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relations.map((rel) => (
|
||||||
|
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||||
|
{rel.relation_name} ({rel.relation_code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>조건명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||||
|
placeholder="예: 활성 품목만 표시"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionField}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">연산자 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.conditionOperator}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONDITION_OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionValue}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||||
|
placeholder="예: active"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterColumn}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterValues}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||||
|
placeholder="예: active,pending"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우선순위 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>우선순위</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||||
|
placeholder="높을수록 먼저 적용"
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>조건부 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 조건부 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function HierarchyTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 확장된 그룹 (레벨 표시)
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 레벨 모달
|
||||||
|
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||||||
|
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||||||
|
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||||||
|
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레벨 폼 데이터
|
||||||
|
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||||||
|
levelOrder: 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// snake_case를 camelCase로 변환하는 함수
|
||||||
|
const transformGroup = (g: any): HierarchyGroup => ({
|
||||||
|
groupId: g.group_id || g.groupId,
|
||||||
|
groupCode: g.group_code || g.groupCode,
|
||||||
|
groupName: g.group_name || g.groupName,
|
||||||
|
description: g.description,
|
||||||
|
hierarchyType: g.hierarchy_type || g.hierarchyType,
|
||||||
|
maxLevels: g.max_levels || g.maxLevels,
|
||||||
|
isFixedLevels: g.is_fixed_levels || g.isFixedLevels,
|
||||||
|
selfRefTable: g.self_ref_table || g.selfRefTable,
|
||||||
|
selfRefIdColumn: g.self_ref_id_column || g.selfRefIdColumn,
|
||||||
|
selfRefParentColumn: g.self_ref_parent_column || g.selfRefParentColumn,
|
||||||
|
selfRefValueColumn: g.self_ref_value_column || g.selfRefValueColumn,
|
||||||
|
selfRefLabelColumn: g.self_ref_label_column || g.selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn: g.self_ref_level_column || g.selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn: g.self_ref_order_column || g.selfRefOrderColumn,
|
||||||
|
bomTable: g.bom_table || g.bomTable,
|
||||||
|
bomParentColumn: g.bom_parent_column || g.bomParentColumn,
|
||||||
|
bomChildColumn: g.bom_child_column || g.bomChildColumn,
|
||||||
|
bomItemTable: g.bom_item_table || g.bomItemTable,
|
||||||
|
bomItemIdColumn: g.bom_item_id_column || g.bomItemIdColumn,
|
||||||
|
bomItemLabelColumn: g.bom_item_label_column || g.bomItemLabelColumn,
|
||||||
|
bomQtyColumn: g.bom_qty_column || g.bomQtyColumn,
|
||||||
|
bomLevelColumn: g.bom_level_column || g.bomLevelColumn,
|
||||||
|
emptyMessage: g.empty_message || g.emptyMessage,
|
||||||
|
noOptionsMessage: g.no_options_message || g.noOptionsMessage,
|
||||||
|
loadingMessage: g.loading_message || g.loadingMessage,
|
||||||
|
companyCode: g.company_code || g.companyCode,
|
||||||
|
isActive: g.is_active || g.isActive,
|
||||||
|
createdBy: g.created_by || g.createdBy,
|
||||||
|
createdDate: g.created_date || g.createdDate,
|
||||||
|
updatedBy: g.updated_by || g.updatedBy,
|
||||||
|
updatedDate: g.updated_date || g.updatedDate,
|
||||||
|
levelCount: g.level_count || g.levelCount || 0,
|
||||||
|
levels: g.levels,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// snake_case를 camelCase로 변환
|
||||||
|
const transformedData = response.data.map(transformGroup);
|
||||||
|
setGroups(transformedData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("계층 그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTables();
|
||||||
|
}, [loadGroups, loadTables]);
|
||||||
|
|
||||||
|
// 그룹 레벨 로드
|
||||||
|
const loadGroupLevels = async (groupCode: string) => {
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getDetail(groupCode);
|
||||||
|
if (response.success && response.data?.levels) {
|
||||||
|
setGroupLevels((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[groupCode]: response.data!.levels || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레벨 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 확장 토글
|
||||||
|
const toggleGroupExpand = async (groupCode: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups);
|
||||||
|
if (newExpanded.has(groupCode)) {
|
||||||
|
newExpanded.delete(groupCode);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(groupCode);
|
||||||
|
if (!groupLevels[groupCode]) {
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 로드 (레벨 폼용)
|
||||||
|
const loadLevelColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setLevelColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setLevelColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (group: HierarchyGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
setFormData({
|
||||||
|
groupCode: group.groupCode,
|
||||||
|
groupName: group.groupName,
|
||||||
|
description: group.description || "",
|
||||||
|
hierarchyType: group.hierarchyType,
|
||||||
|
maxLevels: group.maxLevels,
|
||||||
|
isFixedLevels: group.isFixedLevels || "Y",
|
||||||
|
emptyMessage: group.emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage: group.loadingMessage || "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.groupName || !formData.hierarchyType) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (생성)
|
||||||
|
const handleOpenCreateLevel = (groupCode: string) => {
|
||||||
|
setCurrentGroupCode(groupCode);
|
||||||
|
setEditingLevel(null);
|
||||||
|
const existingLevels = groupLevels[groupCode] || [];
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: existingLevels.length + 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
setLevelColumns([]);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (수정)
|
||||||
|
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||||||
|
setCurrentGroupCode(level.groupCode);
|
||||||
|
setEditingLevel(level);
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: level.levelOrder,
|
||||||
|
levelName: level.levelName,
|
||||||
|
levelCode: level.levelCode || "",
|
||||||
|
tableName: level.tableName,
|
||||||
|
valueColumn: level.valueColumn,
|
||||||
|
labelColumn: level.labelColumn,
|
||||||
|
parentKeyColumn: level.parentKeyColumn || "",
|
||||||
|
orderColumn: level.orderColumn || "",
|
||||||
|
orderDirection: level.orderDirection || "ASC",
|
||||||
|
placeholder: level.placeholder || "",
|
||||||
|
isRequired: level.isRequired || "Y",
|
||||||
|
isSearchable: level.isSearchable || "N",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(level.tableName);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 저장
|
||||||
|
const handleSaveLevel = async () => {
|
||||||
|
if (
|
||||||
|
!levelFormData.levelName ||
|
||||||
|
!levelFormData.tableName ||
|
||||||
|
!levelFormData.valueColumn ||
|
||||||
|
!levelFormData.labelColumn
|
||||||
|
) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingLevel) {
|
||||||
|
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||||||
|
setIsLevelModalOpen(false);
|
||||||
|
await loadGroupLevels(currentGroupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||||||
|
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteLevel(levelId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("레벨이 삭제되었습니다.");
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계층 타입 라벨
|
||||||
|
const getHierarchyTypeLabel = (type: string) => {
|
||||||
|
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
다단계 계층
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<div key={group.groupCode} className="rounded-lg border">
|
||||||
|
<div
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||||||
|
onClick={() => toggleGroupExpand(group.groupCode)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedGroups.has(group.groupCode) ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{group.groupName}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||||||
|
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레벨 목록 */}
|
||||||
|
{expandedGroups.has(group.groupCode) && (
|
||||||
|
<div className="bg-muted/20 border-t p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">레벨 목록</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
레벨 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">순서</TableHead>
|
||||||
|
<TableHead>레벨명</TableHead>
|
||||||
|
<TableHead>테이블</TableHead>
|
||||||
|
<TableHead>값 컬럼</TableHead>
|
||||||
|
<TableHead>부모 키</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(groupLevels[group.groupCode] || []).map((level) => (
|
||||||
|
<TableRow key={level.levelId}>
|
||||||
|
<TableCell>{level.levelOrder}</TableCell>
|
||||||
|
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 그룹 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 지역 계층"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>계층 유형 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.hierarchyType}
|
||||||
|
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HIERARCHY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="계층 구조에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>최대 레벨 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.maxLevels || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
placeholder="예: 4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>고정 레벨 여부</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.isFixedLevels}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">고정</SelectItem>
|
||||||
|
<SelectItem value="N">가변</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 레벨 생성/수정 모달 */}
|
||||||
|
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||||||
|
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨 순서 *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={levelFormData.levelOrder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.levelName}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||||||
|
placeholder="예: 시/도"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{levelFormData.tableName
|
||||||
|
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||||||
|
levelFormData.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setLevelFormData({
|
||||||
|
...levelFormData,
|
||||||
|
tableName: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.valueColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.labelColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.parentKeyColumn || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||||||
|
}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>플레이스홀더</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.placeholder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||||||
|
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function MutualExclusionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||||
|
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필드 목록 (동적 추가)
|
||||||
|
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadExclusions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setExclusions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("상호 배제 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExclusions();
|
||||||
|
loadTables();
|
||||||
|
}, [loadExclusions, loadTables]);
|
||||||
|
|
||||||
|
// 테이블 선택 시 컬럼 로드
|
||||||
|
const loadColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredExclusions = exclusions.filter(
|
||||||
|
(e) =>
|
||||||
|
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingExclusion(null);
|
||||||
|
setFormData({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(["", ""]);
|
||||||
|
setColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||||
|
setEditingExclusion(exclusion);
|
||||||
|
setFormData({
|
||||||
|
exclusionCode: exclusion.exclusionCode,
|
||||||
|
exclusionName: exclusion.exclusionName,
|
||||||
|
fieldNames: exclusion.fieldNames,
|
||||||
|
sourceTable: exclusion.sourceTable,
|
||||||
|
valueColumn: exclusion.valueColumn,
|
||||||
|
labelColumn: exclusion.labelColumn || "",
|
||||||
|
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||||
|
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||||
|
await loadColumns(exclusion.sourceTable);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (exclusionId: number) => {
|
||||||
|
setDeletingExclusionId(exclusionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingExclusionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingExclusionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 추가
|
||||||
|
const addField = () => {
|
||||||
|
setFieldList([...fieldList, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 제거
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
if (fieldList.length <= 2) {
|
||||||
|
toast.error("최소 2개의 필드가 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 값 변경
|
||||||
|
const updateField = (index: number, value: string) => {
|
||||||
|
const newFields = [...fieldList];
|
||||||
|
newFields[index] = value;
|
||||||
|
setFieldList(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필드 목록 합치기
|
||||||
|
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||||
|
if (cleanedFields.length < 2) {
|
||||||
|
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
fieldNames: cleanedFields.join(","),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingExclusion) {
|
||||||
|
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||||
|
} else {
|
||||||
|
response = await mutualExclusionApi.create(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 선택 핸들러
|
||||||
|
const handleTableChange = async (tableName: string) => {
|
||||||
|
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||||
|
await loadColumns(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="배제 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadExclusions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Ban className="h-5 w-5" />
|
||||||
|
상호 배제 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredExclusions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"출발 창고"와 "도착 창고"가 같은 창고를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"현재 부서"와 "이동 부서"가 같은 부서를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>배제 코드</TableHead>
|
||||||
|
<TableHead>배제명</TableHead>
|
||||||
|
<TableHead>대상 필드</TableHead>
|
||||||
|
<TableHead>소스 테이블</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredExclusions.map((exclusion) => (
|
||||||
|
<TableRow key={exclusion.exclusionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{field.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 배제명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.exclusionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||||
|
placeholder="예: 창고 이동 제한"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 필드 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||||
|
<Button variant="outline" size="sm" onClick={addField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fieldList.map((field, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={field}
|
||||||
|
onChange={(e) => updateField(index, e.target.value)}
|
||||||
|
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{fieldList.length > 2 && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 테이블 및 컬럼 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>소스 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.sourceTable
|
||||||
|
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables
|
||||||
|
.filter((t) => t.tableName)
|
||||||
|
.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
sourceTable: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
});
|
||||||
|
await loadColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.valueColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.labelColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.exclusionType}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXCLUSION_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>에러 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.errorMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||||
|
placeholder="동일한 값을 선택할 수 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>상호 배제 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 상호 배제 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,797 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import { useEffect } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Plus, Pencil, Trash2, Link2, RefreshCw, Search, ChevronRight, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
||||||
|
|
||||||
interface TableInfo {
|
/**
|
||||||
tableName: string;
|
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
tableLabel?: string;
|
*/
|
||||||
}
|
export default function CascadingRelationsRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
interface ColumnInfo {
|
|
||||||
columnName: string;
|
|
||||||
columnLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CascadingRelationsPage() {
|
|
||||||
// 목록 상태
|
|
||||||
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
// 테이블/컬럼 목록
|
|
||||||
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
|
||||||
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
|
||||||
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
|
||||||
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
|
||||||
|
|
||||||
// 폼 상태
|
|
||||||
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
|
||||||
relationCode: "",
|
|
||||||
relationName: "",
|
|
||||||
description: "",
|
|
||||||
parentTable: "",
|
|
||||||
parentValueColumn: "",
|
|
||||||
parentLabelColumn: "",
|
|
||||||
childTable: "",
|
|
||||||
childFilterColumn: "",
|
|
||||||
childValueColumn: "",
|
|
||||||
childLabelColumn: "",
|
|
||||||
childOrderColumn: "",
|
|
||||||
childOrderDirection: "ASC",
|
|
||||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
|
||||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
|
||||||
loadingMessage: "로딩 중...",
|
|
||||||
clearOnParentChange: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 고급 설정 토글
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
|
|
||||||
// 목록 조회
|
|
||||||
const loadRelations = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await cascadingRelationApi.getList("Y");
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setRelations(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 테이블 목록 조회
|
|
||||||
const loadTableList = useCallback(async () => {
|
|
||||||
setLoadingTables(true);
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getTableList();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setTableList(
|
|
||||||
response.data.map((t: any) => ({
|
|
||||||
tableName: t.tableName || t.name,
|
|
||||||
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("테이블 목록 조회 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingTables(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컬럼 목록 조회 (수정됨)
|
|
||||||
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
|
||||||
if (!tableName) return;
|
|
||||||
|
|
||||||
if (type === "parent") {
|
|
||||||
setLoadingParentColumns(true);
|
|
||||||
setParentColumns([]);
|
|
||||||
} else {
|
|
||||||
setLoadingChildColumns(true);
|
|
||||||
setChildColumns([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// getColumnList 사용 (getTableColumns가 아님)
|
|
||||||
const response = await tableManagementApi.getColumnList(tableName);
|
|
||||||
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
// 응답 구조: { data: { columns: [...] } }
|
|
||||||
const columnList = response.data.columns || response.data;
|
|
||||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
|
||||||
columnName: c.columnName || c.name,
|
|
||||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (type === "parent") {
|
|
||||||
setParentColumns(columns);
|
|
||||||
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
|
||||||
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
|
||||||
} else {
|
|
||||||
setChildColumns(columns);
|
|
||||||
// 자동 추천
|
|
||||||
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
|
||||||
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컬럼 목록 조회 실패:", error);
|
|
||||||
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
|
||||||
} finally {
|
|
||||||
if (type === "parent") {
|
|
||||||
setLoadingParentColumns(false);
|
|
||||||
} else {
|
|
||||||
setLoadingChildColumns(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
|
||||||
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
|
||||||
if (!tableName) return;
|
|
||||||
|
|
||||||
if (type === "parent") {
|
|
||||||
setLoadingParentColumns(true);
|
|
||||||
} else {
|
|
||||||
setLoadingChildColumns(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getColumnList(tableName);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const columnList = response.data.columns || response.data;
|
|
||||||
|
|
||||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
|
||||||
columnName: c.columnName || c.name,
|
|
||||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (type === "parent") {
|
|
||||||
setParentColumns(columns);
|
|
||||||
} else {
|
|
||||||
setChildColumns(columns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컬럼 목록 조회 실패:", error);
|
|
||||||
} finally {
|
|
||||||
if (type === "parent") {
|
|
||||||
setLoadingParentColumns(false);
|
|
||||||
} else {
|
|
||||||
setLoadingChildColumns(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자동 컬럼 선택 (패턴 매칭)
|
|
||||||
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
|
||||||
// 이미 값이 있으면 스킵
|
|
||||||
if (formData[field]) return;
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
|
||||||
if (found) {
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRelations();
|
router.replace("/admin/cascading-management");
|
||||||
loadTableList();
|
}, [router]);
|
||||||
}, [loadRelations, loadTableList]);
|
|
||||||
|
|
||||||
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
|
||||||
useEffect(() => {
|
|
||||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
|
||||||
if (editingRelation) return;
|
|
||||||
|
|
||||||
if (formData.parentTable) {
|
|
||||||
loadColumns(formData.parentTable, "parent");
|
|
||||||
} else {
|
|
||||||
setParentColumns([]);
|
|
||||||
}
|
|
||||||
}, [formData.parentTable, editingRelation]);
|
|
||||||
|
|
||||||
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
|
||||||
useEffect(() => {
|
|
||||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
|
||||||
if (editingRelation) return;
|
|
||||||
|
|
||||||
if (formData.childTable) {
|
|
||||||
loadColumns(formData.childTable, "child");
|
|
||||||
} else {
|
|
||||||
setChildColumns([]);
|
|
||||||
}
|
|
||||||
}, [formData.childTable, editingRelation]);
|
|
||||||
|
|
||||||
// 관계 코드 자동 생성
|
|
||||||
const generateRelationCode = (parentTable: string, childTable: string) => {
|
|
||||||
if (!parentTable || !childTable) return "";
|
|
||||||
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
|
||||||
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
|
||||||
return `${parent}_${child}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 관계명 자동 생성
|
|
||||||
const generateRelationName = (parentTable: string, childTable: string) => {
|
|
||||||
if (!parentTable || !childTable) return "";
|
|
||||||
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
|
||||||
const childInfo = tableList.find((t) => t.tableName === childTable);
|
|
||||||
const parentName = parentInfo?.tableLabel || parentTable;
|
|
||||||
const childName = childInfo?.tableLabel || childTable;
|
|
||||||
return `${parentName}-${childName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모달 열기 (신규)
|
|
||||||
const handleOpenCreate = () => {
|
|
||||||
setEditingRelation(null);
|
|
||||||
setFormData({
|
|
||||||
relationCode: "",
|
|
||||||
relationName: "",
|
|
||||||
description: "",
|
|
||||||
parentTable: "",
|
|
||||||
parentValueColumn: "",
|
|
||||||
parentLabelColumn: "",
|
|
||||||
childTable: "",
|
|
||||||
childFilterColumn: "",
|
|
||||||
childValueColumn: "",
|
|
||||||
childLabelColumn: "",
|
|
||||||
childOrderColumn: "",
|
|
||||||
childOrderDirection: "ASC",
|
|
||||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
|
||||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
|
||||||
loadingMessage: "로딩 중...",
|
|
||||||
clearOnParentChange: true,
|
|
||||||
});
|
|
||||||
setParentColumns([]);
|
|
||||||
setChildColumns([]);
|
|
||||||
setShowAdvanced(false);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모달 열기 (수정)
|
|
||||||
const handleOpenEdit = async (relation: CascadingRelation) => {
|
|
||||||
setEditingRelation(relation);
|
|
||||||
setShowAdvanced(false);
|
|
||||||
|
|
||||||
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
|
||||||
const loadPromises: Promise<void>[] = [];
|
|
||||||
if (relation.parent_table) {
|
|
||||||
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
|
||||||
}
|
|
||||||
if (relation.child_table) {
|
|
||||||
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컬럼 로드 완료 대기
|
|
||||||
await Promise.all(loadPromises);
|
|
||||||
|
|
||||||
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
|
||||||
setFormData({
|
|
||||||
relationCode: relation.relation_code,
|
|
||||||
relationName: relation.relation_name,
|
|
||||||
description: relation.description || "",
|
|
||||||
parentTable: relation.parent_table,
|
|
||||||
parentValueColumn: relation.parent_value_column,
|
|
||||||
parentLabelColumn: relation.parent_label_column || "",
|
|
||||||
childTable: relation.child_table,
|
|
||||||
childFilterColumn: relation.child_filter_column,
|
|
||||||
childValueColumn: relation.child_value_column,
|
|
||||||
childLabelColumn: relation.child_label_column,
|
|
||||||
childOrderColumn: relation.child_order_column || "",
|
|
||||||
childOrderDirection: relation.child_order_direction || "ASC",
|
|
||||||
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
|
||||||
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
|
||||||
loadingMessage: relation.loading_message || "로딩 중...",
|
|
||||||
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 부모 테이블 선택 시 자동 설정
|
|
||||||
const handleParentTableChange = async (value: string) => {
|
|
||||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
|
||||||
const shouldClearColumns = value !== formData.parentTable;
|
|
||||||
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
parentTable: value,
|
|
||||||
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
|
||||||
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
|
||||||
if (editingRelation && value) {
|
|
||||||
await loadColumnsForEdit(value, "parent");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자식 테이블 선택 시 자동 설정
|
|
||||||
const handleChildTableChange = async (value: string) => {
|
|
||||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
|
||||||
const shouldClearColumns = value !== formData.childTable;
|
|
||||||
|
|
||||||
const newFormData = {
|
|
||||||
...formData,
|
|
||||||
childTable: value,
|
|
||||||
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
|
||||||
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
|
||||||
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
|
||||||
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
|
||||||
if (!editingRelation) {
|
|
||||||
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
|
||||||
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(newFormData);
|
|
||||||
|
|
||||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
|
||||||
if (editingRelation && value) {
|
|
||||||
await loadColumnsForEdit(value, "child");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 저장
|
|
||||||
const handleSave = async () => {
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!formData.parentTable || !formData.parentValueColumn) {
|
|
||||||
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!formData.childTable ||
|
|
||||||
!formData.childFilterColumn ||
|
|
||||||
!formData.childValueColumn ||
|
|
||||||
!formData.childLabelColumn
|
|
||||||
) {
|
|
||||||
toast.error("자식 테이블 설정을 완료해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관계 코드/이름 자동 생성 (비어있으면)
|
|
||||||
const finalData = { ...formData };
|
|
||||||
if (!finalData.relationCode) {
|
|
||||||
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
|
||||||
}
|
|
||||||
if (!finalData.relationName) {
|
|
||||||
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
if (editingRelation) {
|
|
||||||
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
|
||||||
} else {
|
|
||||||
response = await cascadingRelationApi.create(finalData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
|
||||||
setIsModalOpen(false);
|
|
||||||
loadRelations();
|
|
||||||
} else {
|
|
||||||
toast.error(response.message || "저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제
|
|
||||||
const handleDelete = async (relation: CascadingRelation) => {
|
|
||||||
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await cascadingRelationApi.delete(relation.relation_id);
|
|
||||||
if (response.success) {
|
|
||||||
toast.success("연쇄 관계가 삭제되었습니다.");
|
|
||||||
loadRelations();
|
|
||||||
} else {
|
|
||||||
toast.error(response.message || "삭제에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("삭제 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필터링된 목록
|
|
||||||
const filteredRelations = relations.filter(
|
|
||||||
(r) =>
|
|
||||||
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컬럼 셀렉트 렌더링 헬퍼
|
|
||||||
const renderColumnSelect = (
|
|
||||||
value: string,
|
|
||||||
onChange: (v: string) => void,
|
|
||||||
columns: ColumnInfo[],
|
|
||||||
loading: boolean,
|
|
||||||
placeholder: string,
|
|
||||||
disabled?: boolean,
|
|
||||||
) => (
|
|
||||||
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
<span className="text-xs">로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SelectValue placeholder={placeholder} />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{columns.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
|
||||||
) : (
|
|
||||||
columns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnLabel}</span>
|
|
||||||
{col.columnLabel !== col.columnName && (
|
|
||||||
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Card>
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Link2 className="h-5 w-5" />
|
|
||||||
연쇄 관계 관리
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>연쇄 드롭다운에서 사용할 테이블 간 관계를 정의합니다.</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleOpenCreate}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 */}
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>관계명</TableHead>
|
|
||||||
<TableHead>연결</TableHead>
|
|
||||||
<TableHead>상태</TableHead>
|
|
||||||
<TableHead className="w-[100px]">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredRelations.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredRelations.map((relation) => (
|
|
||||||
<TableRow key={relation.relation_id}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{relation.relation_name}</div>
|
|
||||||
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
|
||||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
|
||||||
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
|
||||||
{relation.child_table}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
|
||||||
{relation.is_active === "Y" ? "활성" : "비활성"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 생성/수정 모달 - 간소화된 UI */}
|
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
|
||||||
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Step 1: 부모 테이블 */}
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">테이블</Label>
|
|
||||||
<Select value={formData.parentTable} onValueChange={handleParentTableChange}>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tableList.map((table) => (
|
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
|
||||||
{table.tableLabel || table.tableName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
|
||||||
{renderColumnSelect(
|
|
||||||
formData.parentValueColumn,
|
|
||||||
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
|
||||||
parentColumns,
|
|
||||||
loadingParentColumns,
|
|
||||||
"컬럼 선택",
|
|
||||||
!formData.parentTable,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 2: 자식 테이블 */}
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">테이블</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.childTable}
|
|
||||||
onValueChange={handleChildTableChange}
|
|
||||||
disabled={!formData.parentTable}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="테이블 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tableList.map((table) => (
|
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
|
||||||
{table.tableLabel || table.tableName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
|
||||||
{renderColumnSelect(
|
|
||||||
formData.childFilterColumn,
|
|
||||||
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
|
||||||
childColumns,
|
|
||||||
loadingChildColumns,
|
|
||||||
"컬럼 선택",
|
|
||||||
!formData.childTable,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
|
||||||
{renderColumnSelect(
|
|
||||||
formData.childValueColumn,
|
|
||||||
(v) => setFormData({ ...formData, childValueColumn: v }),
|
|
||||||
childColumns,
|
|
||||||
loadingChildColumns,
|
|
||||||
"컬럼 선택",
|
|
||||||
!formData.childTable,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
|
||||||
{renderColumnSelect(
|
|
||||||
formData.childLabelColumn,
|
|
||||||
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
|
||||||
childColumns,
|
|
||||||
loadingChildColumns,
|
|
||||||
"컬럼 선택",
|
|
||||||
!formData.childTable,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 관계 정보 (자동 생성) */}
|
|
||||||
{formData.parentTable && formData.childTable && (
|
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">관계 코드</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
|
||||||
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
|
||||||
placeholder="자동 생성"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
disabled={!!editingRelation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">관계명</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
|
||||||
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
|
||||||
placeholder="자동 생성"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 고급 설정 토글 */}
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<span>고급 설정</span>
|
|
||||||
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showAdvanced && (
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">설명</Label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="이 관계에 대한 설명..."
|
|
||||||
rows={2}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">상위 미선택 메시지</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.emptyParentMessage}
|
|
||||||
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">옵션 없음 메시지</Label>
|
|
||||||
<Input
|
|
||||||
value={formData.noOptionsMessage}
|
|
||||||
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">부모 변경 시 초기화</Label>
|
|
||||||
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={formData.clearOnParentChange}
|
|
||||||
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
저장 중...
|
|
||||||
</>
|
|
||||||
) : editingRelation ? (
|
|
||||||
"수정"
|
|
||||||
) : (
|
|
||||||
"생성"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
data-slot="command"
|
data-slot="command"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
@@ -37,10 +28,10 @@ function CommandDialog({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@@ -48,127 +39,92 @@ function CommandDialog({
|
|||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
|
||||||
className={cn("overflow-hidden p-0", className)}
|
|
||||||
showCloseButton={showCloseButton}
|
|
||||||
>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||||
data-slot="command-input-wrapper"
|
|
||||||
className="flex h-9 items-center gap-2 border-b px-3"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandList({
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain", className)}
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
onWheel={(e) => {
|
||||||
className
|
e.stopPropagation();
|
||||||
)}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
...props
|
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
data-slot="command-empty"
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandGroup({
|
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandSeparator({
|
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator
|
||||||
data-slot="command-separator"
|
data-slot="command-separator"
|
||||||
className={cn("bg-border -mx-1 h-px", className)}
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandItem({
|
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
data-slot="command-item"
|
data-slot="command-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandShortcut({
|
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
className={cn(
|
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -181,4 +137,4 @@ export {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 커스텀 훅
|
||||||
|
* 마스터 선택 시 여러 필드를 자동으로 입력하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
cascadingAutoFillApi,
|
||||||
|
AutoFillGroup,
|
||||||
|
AutoFillOption,
|
||||||
|
} from "@/lib/api/cascadingAutoFill";
|
||||||
|
|
||||||
|
interface AutoFillMapping {
|
||||||
|
targetField: string;
|
||||||
|
targetLabel: string;
|
||||||
|
value: any;
|
||||||
|
isEditable: boolean;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoFillProps {
|
||||||
|
/** 자동 입력 그룹 코드 */
|
||||||
|
groupCode: string;
|
||||||
|
/** 자동 입력 데이터가 로드되었을 때 호출되는 콜백 */
|
||||||
|
onAutoFill?: (data: Record<string, any>, mappings: AutoFillMapping[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoFillResult {
|
||||||
|
/** 마스터 옵션 목록 */
|
||||||
|
masterOptions: AutoFillOption[];
|
||||||
|
/** 현재 선택된 마스터 값 */
|
||||||
|
selectedMasterValue: string | null;
|
||||||
|
/** 자동 입력된 데이터 */
|
||||||
|
autoFilledData: Record<string, any>;
|
||||||
|
/** 매핑 정보 */
|
||||||
|
mappings: AutoFillMapping[];
|
||||||
|
/** 그룹 정보 */
|
||||||
|
groupInfo: AutoFillGroup | null;
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** 에러 메시지 */
|
||||||
|
error: string | null;
|
||||||
|
/** 마스터 값 선택 핸들러 */
|
||||||
|
selectMasterValue: (value: string) => Promise<void>;
|
||||||
|
/** 마스터 옵션 새로고침 */
|
||||||
|
refreshOptions: () => Promise<void>;
|
||||||
|
/** 자동 입력 데이터 초기화 */
|
||||||
|
clearAutoFill: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoFill({
|
||||||
|
groupCode,
|
||||||
|
onAutoFill,
|
||||||
|
}: UseAutoFillProps): UseAutoFillResult {
|
||||||
|
// 상태
|
||||||
|
const [masterOptions, setMasterOptions] = useState<AutoFillOption[]>([]);
|
||||||
|
const [selectedMasterValue, setSelectedMasterValue] = useState<string | null>(null);
|
||||||
|
const [autoFilledData, setAutoFilledData] = useState<Record<string, any>>({});
|
||||||
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||||
|
const [groupInfo, setGroupInfo] = useState<AutoFillGroup | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 마스터 옵션 로드
|
||||||
|
const loadMasterOptions = useCallback(async () => {
|
||||||
|
if (!groupCode) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 그룹 정보 로드
|
||||||
|
const groupResponse = await cascadingAutoFillApi.getGroupDetail(groupCode);
|
||||||
|
if (groupResponse.success && groupResponse.data) {
|
||||||
|
setGroupInfo(groupResponse.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 옵션 로드
|
||||||
|
const optionsResponse = await cascadingAutoFillApi.getMasterOptions(groupCode);
|
||||||
|
if (optionsResponse.success && optionsResponse.data) {
|
||||||
|
setMasterOptions(optionsResponse.data);
|
||||||
|
} else {
|
||||||
|
setError(optionsResponse.error || "옵션 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "옵션 로드 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupCode]);
|
||||||
|
|
||||||
|
// 마스터 값 선택 시 자동 입력 데이터 로드
|
||||||
|
const selectMasterValue = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
if (!groupCode || !value) {
|
||||||
|
setSelectedMasterValue(null);
|
||||||
|
setAutoFilledData({});
|
||||||
|
setMappings([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedMasterValue(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.getData(groupCode, value);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const data = response.data || {};
|
||||||
|
const mappingInfo = response.mappings || [];
|
||||||
|
|
||||||
|
setAutoFilledData(data);
|
||||||
|
setMappings(mappingInfo);
|
||||||
|
|
||||||
|
// 콜백 호출
|
||||||
|
if (onAutoFill) {
|
||||||
|
onAutoFill(data, mappingInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(response.error || "데이터 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "데이터 로드 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[groupCode, onAutoFill]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 자동 입력 데이터 초기화
|
||||||
|
const clearAutoFill = useCallback(() => {
|
||||||
|
setSelectedMasterValue(null);
|
||||||
|
setAutoFilledData({});
|
||||||
|
setMappings([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupCode) {
|
||||||
|
loadMasterOptions();
|
||||||
|
}
|
||||||
|
}, [groupCode, loadMasterOptions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterOptions,
|
||||||
|
selectedMasterValue,
|
||||||
|
autoFilledData,
|
||||||
|
mappings,
|
||||||
|
groupInfo,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
selectMasterValue,
|
||||||
|
refreshOptions: loadMasterOptions,
|
||||||
|
clearAutoFill,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 화면관리 시스템용 자동 입력 컴포넌트 설정 타입
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface AutoFillConfig {
|
||||||
|
/** 자동 입력 활성화 여부 */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 자동 입력 그룹 코드 */
|
||||||
|
groupCode: string;
|
||||||
|
/** 마스터 필드명 (이 필드 선택 시 자동 입력 트리거) */
|
||||||
|
masterField: string;
|
||||||
|
/** 자동 입력 후 수정 가능 여부 (전체 설정) */
|
||||||
|
allowEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 데이터에 자동 입력 적용
|
||||||
|
*/
|
||||||
|
export function applyAutoFillToFormData(
|
||||||
|
formData: Record<string, any>,
|
||||||
|
autoFilledData: Record<string, any>,
|
||||||
|
mappings: AutoFillMapping[]
|
||||||
|
): Record<string, any> {
|
||||||
|
const result = { ...formData };
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
// 수정 불가능한 필드이거나 기존 값이 없는 경우에만 자동 입력
|
||||||
|
if (!mapping.isEditable || !result[mapping.targetField]) {
|
||||||
|
result[mapping.targetField] = autoFilledData[mapping.targetField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface AutoFillMapping {
|
||||||
|
mappingId?: number;
|
||||||
|
sourceColumn: string;
|
||||||
|
targetField: string;
|
||||||
|
targetLabel?: string;
|
||||||
|
isEditable?: string;
|
||||||
|
isRequired?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillGroup {
|
||||||
|
groupId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
masterTable: string;
|
||||||
|
masterValueColumn: string;
|
||||||
|
masterLabelColumn?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
mappingCount?: number;
|
||||||
|
mappings?: AutoFillMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillDataResponse {
|
||||||
|
data: Record<string, any>;
|
||||||
|
mappings: Array<{
|
||||||
|
targetField: string;
|
||||||
|
targetLabel: string;
|
||||||
|
value: any;
|
||||||
|
isEditable: boolean;
|
||||||
|
isRequired: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillGroups(isActive?: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isActive) params.append("isActive", isActive);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/groups?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
|
*/
|
||||||
|
export async function getAutoFillGroupDetail(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/groups/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 생성
|
||||||
|
*/
|
||||||
|
export async function createAutoFillGroup(data: {
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
masterTable: string;
|
||||||
|
masterValueColumn: string;
|
||||||
|
masterLabelColumn?: string;
|
||||||
|
mappings?: AutoFillMapping[];
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-auto-fill/groups", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 수정
|
||||||
|
*/
|
||||||
|
export async function updateAutoFillGroup(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<AutoFillGroup> & { mappings?: AutoFillMapping[] }
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-auto-fill/groups/${groupCode}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteAutoFillGroup(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-auto-fill/groups/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 옵션 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillMasterOptions(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillOption[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/options/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터 옵션 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 데이터 조회
|
||||||
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillData(
|
||||||
|
groupCode: string,
|
||||||
|
masterValue: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
mappings?: AutoFillDataResponse["mappings"];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-auto-fill/data/${groupCode}?masterValue=${encodeURIComponent(masterValue)}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 데이터 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const cascadingAutoFillApi = {
|
||||||
|
getGroups: getAutoFillGroups,
|
||||||
|
getGroupDetail: getAutoFillGroupDetail,
|
||||||
|
createGroup: createAutoFillGroup,
|
||||||
|
updateGroup: updateAutoFillGroup,
|
||||||
|
deleteGroup: deleteAutoFillGroup,
|
||||||
|
getMasterOptions: getAutoFillMasterOptions,
|
||||||
|
getData: getAutoFillData,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface CascadingCondition {
|
||||||
|
conditionId?: number;
|
||||||
|
relationType: string; // "RELATION" | "HIERARCHY"
|
||||||
|
relationCode: string;
|
||||||
|
conditionName: string;
|
||||||
|
conditionField: string;
|
||||||
|
conditionOperator: string; // "EQ" | "NEQ" | "CONTAINS" | "IN" | "GT" | "LT" 등
|
||||||
|
conditionValue: string;
|
||||||
|
filterColumn: string;
|
||||||
|
filterValues: string; // 콤마로 구분된 값들
|
||||||
|
priority?: number;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연산자 목록
|
||||||
|
export const CONDITION_OPERATORS = [
|
||||||
|
{ value: "EQ", label: "같음 (=)" },
|
||||||
|
{ value: "NEQ", label: "같지 않음 (!=)" },
|
||||||
|
{ value: "CONTAINS", label: "포함" },
|
||||||
|
{ value: "NOT_CONTAINS", label: "포함하지 않음" },
|
||||||
|
{ value: "STARTS_WITH", label: "시작" },
|
||||||
|
{ value: "ENDS_WITH", label: "끝" },
|
||||||
|
{ value: "IN", label: "목록에 포함" },
|
||||||
|
{ value: "NOT_IN", label: "목록에 미포함" },
|
||||||
|
{ value: "GT", label: "보다 큼 (>)" },
|
||||||
|
{ value: "GTE", label: "보다 크거나 같음 (>=)" },
|
||||||
|
{ value: "LT", label: "보다 작음 (<)" },
|
||||||
|
{ value: "LTE", label: "보다 작거나 같음 (<=)" },
|
||||||
|
{ value: "IS_NULL", label: "비어있음" },
|
||||||
|
{ value: "IS_NOT_NULL", label: "비어있지 않음" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getConditions(params?: {
|
||||||
|
isActive?: string;
|
||||||
|
relationCode?: string;
|
||||||
|
relationType?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.isActive) searchParams.append("isActive", params.isActive);
|
||||||
|
if (params?.relationCode) searchParams.append("relationCode", params.relationCode);
|
||||||
|
if (params?.relationType) searchParams.append("relationType", params.relationType);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-conditions?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getConditionDetail(conditionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-conditions/${conditionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 생성
|
||||||
|
*/
|
||||||
|
export async function createCondition(data: Omit<CascadingCondition, "conditionId">): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-conditions", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 수정
|
||||||
|
*/
|
||||||
|
export async function updateCondition(
|
||||||
|
conditionId: number,
|
||||||
|
data: Partial<CascadingCondition>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-conditions/${conditionId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteCondition(conditionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-conditions/${conditionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
|
*/
|
||||||
|
export async function getFilteredOptions(
|
||||||
|
relationCode: string,
|
||||||
|
params: {
|
||||||
|
conditionFieldValue?: string;
|
||||||
|
parentValue?: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
appliedCondition?: { conditionId: number; conditionName: string } | null;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.conditionFieldValue) searchParams.append("conditionFieldValue", params.conditionFieldValue);
|
||||||
|
if (params.parentValue) searchParams.append("parentValue", params.parentValue);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-conditions/filtered-options/${relationCode}?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 필터링 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const cascadingConditionApi = {
|
||||||
|
getList: getConditions,
|
||||||
|
getDetail: getConditionDetail,
|
||||||
|
create: createCondition,
|
||||||
|
update: updateCondition,
|
||||||
|
delete: deleteCondition,
|
||||||
|
getFilteredOptions,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface HierarchyLevel {
|
||||||
|
levelId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
companyCode?: string;
|
||||||
|
levelOrder: number;
|
||||||
|
levelName: string;
|
||||||
|
levelCode?: string;
|
||||||
|
tableName: string;
|
||||||
|
valueColumn: string;
|
||||||
|
labelColumn: string;
|
||||||
|
parentKeyColumn?: string;
|
||||||
|
filterColumn?: string;
|
||||||
|
filterValue?: string;
|
||||||
|
orderColumn?: string;
|
||||||
|
orderDirection?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
isRequired?: string;
|
||||||
|
isSearchable?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyGroup {
|
||||||
|
groupId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
hierarchyType: "MULTI_TABLE" | "SELF_REFERENCE" | "BOM" | "TREE";
|
||||||
|
maxLevels?: number;
|
||||||
|
isFixedLevels?: string;
|
||||||
|
// Self-reference 설정
|
||||||
|
selfRefTable?: string;
|
||||||
|
selfRefIdColumn?: string;
|
||||||
|
selfRefParentColumn?: string;
|
||||||
|
selfRefValueColumn?: string;
|
||||||
|
selfRefLabelColumn?: string;
|
||||||
|
selfRefLevelColumn?: string;
|
||||||
|
selfRefOrderColumn?: string;
|
||||||
|
// BOM 설정
|
||||||
|
bomTable?: string;
|
||||||
|
bomParentColumn?: string;
|
||||||
|
bomChildColumn?: string;
|
||||||
|
bomItemTable?: string;
|
||||||
|
bomItemIdColumn?: string;
|
||||||
|
bomItemLabelColumn?: string;
|
||||||
|
bomQtyColumn?: string;
|
||||||
|
bomLevelColumn?: string;
|
||||||
|
// 메시지
|
||||||
|
emptyMessage?: string;
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
// 메타
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
// 조회 시 포함
|
||||||
|
levels?: HierarchyLevel[];
|
||||||
|
levelCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계층 타입
|
||||||
|
export const HIERARCHY_TYPES = [
|
||||||
|
{ value: "MULTI_TABLE", label: "다중 테이블 (국가>도시>구)" },
|
||||||
|
{ value: "SELF_REFERENCE", label: "자기 참조 (조직도)" },
|
||||||
|
{ value: "BOM", label: "BOM (부품 구조)" },
|
||||||
|
{ value: "TREE", label: "트리 (카테고리)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getHierarchyGroups(params?: {
|
||||||
|
isActive?: string;
|
||||||
|
hierarchyType?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.isActive) searchParams.append("isActive", params.isActive);
|
||||||
|
if (params?.hierarchyType) searchParams.append("hierarchyType", params.hierarchyType);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-hierarchy?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
|
*/
|
||||||
|
export async function getHierarchyGroupDetail(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-hierarchy/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 생성
|
||||||
|
*/
|
||||||
|
export async function createHierarchyGroup(
|
||||||
|
data: Omit<HierarchyGroup, "groupId"> & { levels?: Partial<HierarchyLevel>[] }
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-hierarchy", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 수정
|
||||||
|
*/
|
||||||
|
export async function updateHierarchyGroup(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<HierarchyGroup>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-hierarchy/${groupCode}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteHierarchyGroup(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-hierarchy/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 추가
|
||||||
|
*/
|
||||||
|
export async function addLevel(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<HierarchyLevel>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyLevel;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/cascading-hierarchy/${groupCode}/levels`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 추가 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 수정
|
||||||
|
*/
|
||||||
|
export async function updateLevel(
|
||||||
|
levelId: number,
|
||||||
|
data: Partial<HierarchyLevel>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyLevel;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-hierarchy/levels/${levelId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteLevel(levelId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-hierarchy/levels/${levelId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레벨의 옵션 조회
|
||||||
|
*/
|
||||||
|
export async function getLevelOptions(
|
||||||
|
groupCode: string,
|
||||||
|
levelOrder: number,
|
||||||
|
parentValue?: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
levelInfo?: {
|
||||||
|
levelId: number;
|
||||||
|
levelName: string;
|
||||||
|
placeholder: string;
|
||||||
|
isRequired: string;
|
||||||
|
isSearchable: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (parentValue) params.append("parentValue", parentValue);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-hierarchy/${groupCode}/options/${levelOrder}?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const hierarchyApi = {
|
||||||
|
getGroups: getHierarchyGroups,
|
||||||
|
getDetail: getHierarchyGroupDetail,
|
||||||
|
createGroup: createHierarchyGroup,
|
||||||
|
updateGroup: updateHierarchyGroup,
|
||||||
|
deleteGroup: deleteHierarchyGroup,
|
||||||
|
addLevel,
|
||||||
|
updateLevel,
|
||||||
|
deleteLevel,
|
||||||
|
getLevelOptions,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface MutualExclusion {
|
||||||
|
exclusionId?: number;
|
||||||
|
exclusionCode: string;
|
||||||
|
exclusionName: string;
|
||||||
|
fieldNames: string; // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||||
|
sourceTable: string;
|
||||||
|
valueColumn: string;
|
||||||
|
labelColumn?: string;
|
||||||
|
exclusionType?: string; // "SAME_VALUE"
|
||||||
|
errorMessage?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배제 타입 목록
|
||||||
|
export const EXCLUSION_TYPES = [
|
||||||
|
{ value: "SAME_VALUE", label: "동일 값 배제" },
|
||||||
|
{ value: "RELATED", label: "관련 값 배제 (예정)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getExclusions(isActive?: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isActive) params.append("isActive", isActive);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-exclusions?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getExclusionDetail(exclusionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-exclusions/${exclusionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 생성
|
||||||
|
*/
|
||||||
|
export async function createExclusion(data: Omit<MutualExclusion, "exclusionId">): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-exclusions", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 수정
|
||||||
|
*/
|
||||||
|
export async function updateExclusion(
|
||||||
|
exclusionId: number,
|
||||||
|
data: Partial<MutualExclusion>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-exclusions/${exclusionId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteExclusion(exclusionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-exclusions/${exclusionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 검증
|
||||||
|
*/
|
||||||
|
export async function validateExclusion(
|
||||||
|
exclusionCode: string,
|
||||||
|
fieldValues: Record<string, string>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
isValid: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
conflictingFields: string[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/cascading-exclusions/validate/${exclusionCode}`, {
|
||||||
|
fieldValues,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 검증 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배제된 옵션 조회 (다른 필드에서 선택한 값 제외)
|
||||||
|
*/
|
||||||
|
export async function getExcludedOptions(
|
||||||
|
exclusionCode: string,
|
||||||
|
params: {
|
||||||
|
currentField?: string;
|
||||||
|
selectedValues?: string; // 콤마로 구분된 값들
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.currentField) searchParams.append("currentField", params.currentField);
|
||||||
|
if (params.selectedValues) searchParams.append("selectedValues", params.selectedValues);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-exclusions/options/${exclusionCode}?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("배제된 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const mutualExclusionApi = {
|
||||||
|
getList: getExclusions,
|
||||||
|
getDetail: getExclusionDetail,
|
||||||
|
create: createExclusion,
|
||||||
|
update: updateExclusion,
|
||||||
|
delete: deleteExclusion,
|
||||||
|
validate: validateExclusion,
|
||||||
|
getExcludedOptions,
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user