연쇄 통합관리
This commit is contained in:
parent
c71b958a05
commit
08575c296e
|
|
@ -77,6 +77,10 @@ import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이
|
|||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
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 collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -249,6 +253,10 @@ app.use("/api/orders", orderRoutes); // 수주 관리
|
|||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
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/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -1256,8 +1256,17 @@ export async function updateMenu(
|
|||
}
|
||||
}
|
||||
|
||||
const requestCompanyCode =
|
||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
||||
let requestCompanyCode =
|
||||
menuData.companyCode || menuData.company_code;
|
||||
|
||||
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||
if (
|
||||
requestCompanyCode === "none" ||
|
||||
requestCompanyCode === "" ||
|
||||
!requestCompanyCode
|
||||
) {
|
||||
requestCompanyCode = currentMenu.company_code;
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
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 { 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";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||
*/
|
||||
export default function CascadingRelationsRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
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>
|
||||
);
|
||||
router.replace("/admin/cascading-management");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<Card>
|
||||
<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 className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,23 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
|
|
@ -37,10 +28,10 @@ function CommandDialog({
|
|||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
|
|
@ -48,127 +39,92 @@ function CommandDialog({
|
|||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<DialogContent 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">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<div 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" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain", className)}
|
||||
onWheel={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
@ -181,4 +137,4 @@ export {
|
|||
CommandItem,
|
||||
CommandShortcut,
|
||||
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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue