연쇄관계 관리
This commit is contained in:
parent
ba817980f0
commit
c71b958a05
|
|
@ -76,6 +76,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -247,6 +248,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,719 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 목록 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelations = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active,
|
||||||
|
created_by,
|
||||||
|
created_date,
|
||||||
|
updated_by,
|
||||||
|
updated_date
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND (company_code = $${paramIndex} OR company_code = '*')`;
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터링
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
query += ` AND is_active = $${paramIndex}`;
|
||||||
|
params.push(isActive);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY relation_name ASC`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 목록 조회", {
|
||||||
|
companyCode,
|
||||||
|
count: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 상세 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationById = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active,
|
||||||
|
created_by,
|
||||||
|
created_date,
|
||||||
|
updated_by,
|
||||||
|
updated_date
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [id];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND (company_code = $2 OR company_code = '*')`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 코드로 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationByCode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [code];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (회사 전용 관계 우선, 없으면 공통 관계)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND (company_code = $2 OR company_code = '*')`;
|
||||||
|
params.push(companyCode);
|
||||||
|
query += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`;
|
||||||
|
} else {
|
||||||
|
query += ` LIMIT 1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 생성
|
||||||
|
*/
|
||||||
|
export const createCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
const {
|
||||||
|
relationCode,
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!relationCode || !relationName || !parentTable || !parentValueColumn ||
|
||||||
|
!childTable || !childFilterColumn || !childValueColumn || !childLabelColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 코드 체크
|
||||||
|
const duplicateCheck = await pool.query(
|
||||||
|
`SELECT relation_id FROM cascading_relation
|
||||||
|
WHERE relation_code = $1 AND company_code = $2`,
|
||||||
|
[relationCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 관계 코드입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO cascading_relation (
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
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, 'Y', $18, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
relationCode,
|
||||||
|
relationName,
|
||||||
|
description || null,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn || null,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn || null,
|
||||||
|
childOrderDirection || "ASC",
|
||||||
|
emptyParentMessage || "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage || "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage || "로딩 중...",
|
||||||
|
clearOnParentChange !== false ? "Y" : "N",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 생성", {
|
||||||
|
relationId: result.rows[0].relation_id,
|
||||||
|
relationCode,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
message: "연쇄 관계가 생성되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 수정
|
||||||
|
*/
|
||||||
|
export const updateCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
const {
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
|
const existingCheck = await pool.query(
|
||||||
|
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
|
||||||
|
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||||
|
if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE cascading_relation SET
|
||||||
|
relation_name = COALESCE($1, relation_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
parent_table = COALESCE($3, parent_table),
|
||||||
|
parent_value_column = COALESCE($4, parent_value_column),
|
||||||
|
parent_label_column = COALESCE($5, parent_label_column),
|
||||||
|
child_table = COALESCE($6, child_table),
|
||||||
|
child_filter_column = COALESCE($7, child_filter_column),
|
||||||
|
child_value_column = COALESCE($8, child_value_column),
|
||||||
|
child_label_column = COALESCE($9, child_label_column),
|
||||||
|
child_order_column = COALESCE($10, child_order_column),
|
||||||
|
child_order_direction = COALESCE($11, child_order_direction),
|
||||||
|
empty_parent_message = COALESCE($12, empty_parent_message),
|
||||||
|
no_options_message = COALESCE($13, no_options_message),
|
||||||
|
loading_message = COALESCE($14, loading_message),
|
||||||
|
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
|
||||||
|
is_active = COALESCE($16, is_active),
|
||||||
|
updated_by = $17,
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE relation_id = $18
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null,
|
||||||
|
isActive !== undefined ? (isActive ? "Y" : "N") : null,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 수정", {
|
||||||
|
relationId: id,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
message: "연쇄 관계가 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
|
const existingCheck = await pool.query(
|
||||||
|
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
|
||||||
|
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||||
|
if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소프트 삭제 (is_active = 'N')
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 삭제", {
|
||||||
|
relationId: id,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "연쇄 관계가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||||
|
* parent_table에서 전체 옵션을 조회합니다.
|
||||||
|
*/
|
||||||
|
export const getParentOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 관계 정보 조회
|
||||||
|
let relationQuery = `
|
||||||
|
SELECT
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const relationParams: any[] = [code];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND (company_code = $2 OR company_code = '*')`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
relationQuery += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`;
|
||||||
|
} else {
|
||||||
|
relationQuery += ` LIMIT 1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationResult = await pool.query(relationQuery, relationParams);
|
||||||
|
|
||||||
|
if (relationResult.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
|
// 라벨 컬럼이 없으면 값 컬럼 사용
|
||||||
|
const labelColumn = relation.parent_label_column || relation.parent_value_column;
|
||||||
|
|
||||||
|
// 부모 옵션 조회
|
||||||
|
let optionsQuery = `
|
||||||
|
SELECT
|
||||||
|
${relation.parent_value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${relation.parent_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
const tableInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.parent_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
|
||||||
|
if (tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*") {
|
||||||
|
optionsQuery += ` AND (company_code = $1 OR company_code = '*')`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status 컬럼이 있으면 활성 상태만 조회
|
||||||
|
const statusInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'status'`,
|
||||||
|
[relation.parent_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
|
||||||
|
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
|
||||||
|
|
||||||
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
|
logger.info("부모 옵션 조회", {
|
||||||
|
relationCode: code,
|
||||||
|
parentTable: relation.parent_table,
|
||||||
|
optionsCount: optionsResult.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("부모 옵션 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부모 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
|
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||||
|
*/
|
||||||
|
export const getCascadingOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!parentValue) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
message: "부모 값이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 정보 조회
|
||||||
|
let relationQuery = `
|
||||||
|
SELECT
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const relationParams: any[] = [code];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND (company_code = $2 OR company_code = '*')`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
relationQuery += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`;
|
||||||
|
} else {
|
||||||
|
relationQuery += ` LIMIT 1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationResult = await pool.query(relationQuery, relationParams);
|
||||||
|
|
||||||
|
if (relationResult.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
|
// 자식 옵션 조회
|
||||||
|
let optionsQuery = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE ${relation.child_filter_column} = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
const tableInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionsParams: any[] = [parentValue];
|
||||||
|
|
||||||
|
if (tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*") {
|
||||||
|
optionsQuery += ` AND (company_code = $2 OR company_code = '*')`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (relation.child_order_column) {
|
||||||
|
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
|
logger.info("연쇄 옵션 조회", {
|
||||||
|
relationCode: code,
|
||||||
|
parentValue,
|
||||||
|
optionsCount: optionsResult.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 옵션 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
getCascadingRelations,
|
||||||
|
getCascadingRelationById,
|
||||||
|
getCascadingRelationByCode,
|
||||||
|
createCascadingRelation,
|
||||||
|
updateCascadingRelation,
|
||||||
|
deleteCascadingRelation,
|
||||||
|
getCascadingOptions,
|
||||||
|
getParentOptions,
|
||||||
|
} from "../controllers/cascadingRelationController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 조회
|
||||||
|
router.get("/", getCascadingRelations);
|
||||||
|
|
||||||
|
// 연쇄 관계 상세 조회 (ID)
|
||||||
|
router.get("/:id", getCascadingRelationById);
|
||||||
|
|
||||||
|
// 연쇄 관계 코드로 조회
|
||||||
|
router.get("/code/:code", getCascadingRelationByCode);
|
||||||
|
|
||||||
|
// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||||
|
router.get("/parent-options/:code", getParentOptions);
|
||||||
|
|
||||||
|
// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용)
|
||||||
|
router.get("/options/:code", getCascadingOptions);
|
||||||
|
|
||||||
|
// 연쇄 관계 생성
|
||||||
|
router.post("/", createCascadingRelation);
|
||||||
|
|
||||||
|
// 연쇄 관계 수정
|
||||||
|
router.put("/:id", updateCascadingRelation);
|
||||||
|
|
||||||
|
// 연쇄 관계 삭제
|
||||||
|
router.delete("/:id", deleteCascadingRelation);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# 065 마이그레이션 실행 가이드
|
||||||
|
|
||||||
|
## 연쇄 드롭다운 관계 관리 테이블 생성
|
||||||
|
|
||||||
|
### 실행 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 환경
|
||||||
|
psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql
|
||||||
|
|
||||||
|
# Docker 환경
|
||||||
|
docker exec -i <postgres_container> psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql
|
||||||
|
|
||||||
|
# 또는 DBeaver/pgAdmin에서 직접 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 생성되는 테이블
|
||||||
|
|
||||||
|
- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블
|
||||||
|
|
||||||
|
### 샘플 데이터
|
||||||
|
|
||||||
|
마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다.
|
||||||
|
|
||||||
|
### 확인 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM cascading_relation;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,797 @@
|
||||||
|
"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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운(Cascading Dropdown) 컴포넌트
|
||||||
|
*
|
||||||
|
* 부모 필드의 값에 따라 옵션이 동적으로 변경되는 드롭다운입니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 창고 → 위치 연쇄 드롭다운
|
||||||
|
* <CascadingDropdown
|
||||||
|
* config={{
|
||||||
|
* enabled: true,
|
||||||
|
* parentField: "warehouse_code",
|
||||||
|
* sourceTable: "warehouse_location",
|
||||||
|
* parentKeyColumn: "warehouse_id",
|
||||||
|
* valueColumn: "location_code",
|
||||||
|
* labelColumn: "location_name",
|
||||||
|
* }}
|
||||||
|
* parentValue={formData.warehouse_code}
|
||||||
|
* value={formData.location_code}
|
||||||
|
* onChange={(value) => setFormData({ ...formData, location_code: value })}
|
||||||
|
* placeholder="위치 선택"
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useCascadingDropdown, CascadingOption } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface CascadingDropdownProps {
|
||||||
|
/** 연쇄 드롭다운 설정 */
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
|
||||||
|
/** 부모 필드의 현재 값 */
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
|
||||||
|
/** 현재 선택된 값 */
|
||||||
|
value?: string;
|
||||||
|
|
||||||
|
/** 값 변경 핸들러 */
|
||||||
|
onChange?: (value: string, option?: CascadingOption) => void;
|
||||||
|
|
||||||
|
/** 플레이스홀더 */
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
/** 비활성화 여부 */
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/** 읽기 전용 여부 */
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
/** 필수 입력 여부 */
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/** 추가 클래스명 */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** 추가 스타일 */
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
/** 검색 가능 여부 */
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
required = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
searchable = false,
|
||||||
|
}: CascadingDropdownProps) {
|
||||||
|
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
options,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
getLabelByValue,
|
||||||
|
} = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부모 값 변경 시 자동 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.clearOnParentChange !== false) {
|
||||||
|
if (prevParentValueRef.current !== undefined &&
|
||||||
|
prevParentValueRef.current !== parentValue &&
|
||||||
|
value) {
|
||||||
|
// 부모 값이 변경되면 현재 값 초기화
|
||||||
|
onChange?.("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevParentValueRef.current = parentValue;
|
||||||
|
}, [parentValue, config.clearOnParentChange, value, onChange]);
|
||||||
|
|
||||||
|
// 부모 값이 없을 때 메시지
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경 핸들러
|
||||||
|
const handleValueChange = (newValue: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt) => opt.value === newValue);
|
||||||
|
onChange?.(newValue, selectedOption);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비활성화 상태 계산
|
||||||
|
const isDisabled = disabled || readOnly || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)} style={style}>
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
!parentValue && "text-muted-foreground",
|
||||||
|
error && "border-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{config.loadingMessage || "로딩 중..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive mt-1 text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CascadingDropdown;
|
||||||
|
|
||||||
|
|
@ -55,6 +55,83 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 컴포넌트 (폼 내부용)
|
||||||
|
*/
|
||||||
|
interface CascadingDropdownInFormProps {
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { options, loading } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={(newValue) => onChange?.(newValue)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={className}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
|
|
@ -1434,6 +1511,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
|
||||||
|
if (cascadingConfig?.enabled) {
|
||||||
|
const parentValue = editFormData[cascadingConfig.parentField];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CascadingDropdownInForm
|
||||||
|
config={cascadingConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={commonProps.placeholder}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const options = detailSettings?.options || [];
|
const options = detailSettings?.options || [];
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
|
|
@ -1670,9 +1766,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
|
||||||
|
if (cascadingConfigAdd?.enabled) {
|
||||||
|
const parentValueAdd = addFormData[cascadingConfigAdd.parentField];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CascadingDropdownInForm
|
||||||
|
config={cascadingConfigAdd}
|
||||||
|
parentValue={parentValueAdd}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={commonProps.placeholder}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const options = detailSettings?.options || [];
|
const optionsAdd = detailSettings?.options || [];
|
||||||
if (options.length > 0) {
|
if (optionsAdd.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||||
|
|
@ -1680,7 +1795,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<SelectValue placeholder={commonProps.placeholder} />
|
<SelectValue placeholder={commonProps.placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.map((option: any, index: number) => (
|
{optionsAdd.map((option: any, index: number) => (
|
||||||
<SelectItem key={index} value={option.value || option}>
|
<SelectItem key={index} value={option.value || option}>
|
||||||
{option.label || option}
|
{option.label || option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1696,20 +1811,20 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "radio":
|
case "radio":
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const radioOptions = detailSettings?.options || [];
|
const radioOptionsAdd = detailSettings?.options || [];
|
||||||
const defaultValue = detailSettings?.defaultValue;
|
const defaultValueAdd = detailSettings?.defaultValue;
|
||||||
|
|
||||||
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptionsAdd.length > 0) {
|
||||||
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
||||||
if (!value && defaultValue) {
|
if (!value && defaultValueAdd) {
|
||||||
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
|
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{radioOptions.map((option: any, index: number) => (
|
{radioOptionsAdd.map((option: any, index: number) => (
|
||||||
<div key={index} className="flex items-center space-x-2">
|
<div key={index} className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
import { CalendarIcon, File, Upload, X, Loader2 } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
import { ko } from "date-fns/locale";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
|
@ -49,6 +51,96 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||||
|
* InteractiveScreenViewer 내에서 사용
|
||||||
|
*/
|
||||||
|
interface CascadingDropdownWrapperProps {
|
||||||
|
/** 직접 설정 방식 */
|
||||||
|
config?: CascadingDropdownConfig;
|
||||||
|
/** 공통 관리 방식 (관계 코드) */
|
||||||
|
relationCode?: string;
|
||||||
|
/** 부모 필드명 (relationCode 사용 시 필요) */
|
||||||
|
parentFieldName?: string;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
|
||||||
|
config,
|
||||||
|
relationCode,
|
||||||
|
parentFieldName,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
}) => {
|
||||||
|
const { options, loading, error, relationConfig } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
relationCode,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
|
||||||
|
const effectiveConfig = config || relationConfig;
|
||||||
|
|
||||||
|
// 부모 값이 없을 때 메시지
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return effectiveConfig?.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return effectiveConfig?.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return effectiveConfig?.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={(newValue) => onChange?.(newValue)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-full w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
allComponents: ComponentData[];
|
allComponents: ComponentData[];
|
||||||
|
|
@ -697,10 +789,55 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
searchable: config?.searchable,
|
searchable: config?.searchable,
|
||||||
placeholder: config?.placeholder,
|
placeholder: config?.placeholder,
|
||||||
defaultValue: config?.defaultValue,
|
defaultValue: config?.defaultValue,
|
||||||
|
cascading: config?.cascading,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
|
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
|
||||||
|
if (config?.cascadingRelationCode && config?.cascadingParentField) {
|
||||||
|
const parentFieldValue = formData[config.cascadingParentField];
|
||||||
|
|
||||||
|
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
|
||||||
|
relationCode: config.cascadingRelationCode,
|
||||||
|
parentField: config.cascadingParentField,
|
||||||
|
parentValue: parentFieldValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return applyStyles(
|
||||||
|
<CascadingDropdownWrapper
|
||||||
|
relationCode={config.cascadingRelationCode}
|
||||||
|
parentFieldName={config.cascadingParentField}
|
||||||
|
parentValue={parentFieldValue}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
|
placeholder={finalPlaceholder}
|
||||||
|
disabled={readonly}
|
||||||
|
required={required}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
|
||||||
|
if (config?.cascading?.enabled) {
|
||||||
|
const cascadingConfig = config.cascading;
|
||||||
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
||||||
|
return applyStyles(
|
||||||
|
<CascadingDropdownWrapper
|
||||||
|
config={cascadingConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
|
placeholder={finalPlaceholder}
|
||||||
|
disabled={readonly}
|
||||||
|
required={required}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 Select
|
||||||
const options = config?.options || [
|
const options = config?.options || [
|
||||||
{ label: "옵션 1", value: "option1" },
|
{ label: "옵션 1", value: "option1" },
|
||||||
{ label: "옵션 2", value: "option2" },
|
{ label: "옵션 2", value: "option2" },
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, Trash2, ChevronDown, List } from "lucide-react";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, List, Link2, ExternalLink } from "lucide-react";
|
||||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||||
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
||||||
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -38,7 +41,18 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
required: config.required || false,
|
required: config.required || false,
|
||||||
readonly: config.readonly || false,
|
readonly: config.readonly || false,
|
||||||
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
|
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||||
|
cascadingRelationCode: config.cascadingRelationCode,
|
||||||
|
cascadingParentField: config.cascadingParentField,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 설정 상태
|
||||||
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
|
const [selectedRelationCode, setSelectedRelationCode] = useState(config.cascadingRelationCode || "");
|
||||||
|
const [selectedParentField, setSelectedParentField] = useState(config.cascadingParentField || "");
|
||||||
|
|
||||||
|
// 연쇄 관계 목록
|
||||||
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
// 새 옵션 추가용 상태
|
// 새 옵션 추가용 상태
|
||||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||||
|
|
@ -66,6 +80,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
required: currentConfig.required || false,
|
required: currentConfig.required || false,
|
||||||
readonly: currentConfig.readonly || false,
|
readonly: currentConfig.readonly || false,
|
||||||
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||||
|
cascadingRelationCode: currentConfig.cascadingRelationCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 입력 필드 로컬 상태도 동기화
|
// 입력 필드 로컬 상태도 동기화
|
||||||
|
|
@ -73,7 +88,34 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
placeholder: currentConfig.placeholder || "",
|
placeholder: currentConfig.placeholder || "",
|
||||||
emptyMessage: currentConfig.emptyMessage || "",
|
emptyMessage: currentConfig.emptyMessage || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 설정 동기화
|
||||||
|
setCascadingEnabled(!!currentConfig.cascadingRelationCode);
|
||||||
|
setSelectedRelationCode(currentConfig.cascadingRelationCode || "");
|
||||||
|
setSelectedParentField(currentConfig.cascadingParentField || "");
|
||||||
}, [widget.webTypeConfig]);
|
}, [widget.webTypeConfig]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (cascadingEnabled && relationList.length === 0) {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드 함수
|
||||||
|
const loadRelationList = async () => {
|
||||||
|
setLoadingRelations(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelationList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 설정 업데이트 핸들러
|
// 설정 업데이트 핸들러
|
||||||
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
|
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
|
||||||
|
|
@ -82,6 +124,38 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
onUpdateProperty("webTypeConfig", newConfig);
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 활성화/비활성화
|
||||||
|
const handleCascadingToggle = (enabled: boolean) => {
|
||||||
|
setCascadingEnabled(enabled);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 비활성화 시 관계 코드 제거
|
||||||
|
setSelectedRelationCode("");
|
||||||
|
const newConfig = { ...localConfig, cascadingRelationCode: undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
} else {
|
||||||
|
// 활성화 시 관계 목록 로드
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연쇄 관계 선택
|
||||||
|
const handleRelationSelect = (code: string) => {
|
||||||
|
setSelectedRelationCode(code);
|
||||||
|
const newConfig = { ...localConfig, cascadingRelationCode: code || undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 필드 선택
|
||||||
|
const handleParentFieldChange = (field: string) => {
|
||||||
|
setSelectedParentField(field);
|
||||||
|
const newConfig = { ...localConfig, cascadingParentField: field || undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
// 옵션 추가
|
// 옵션 추가
|
||||||
const addOption = () => {
|
const addOption = () => {
|
||||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||||
|
|
@ -167,6 +241,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
updateConfig("options", defaultOptionSets[setName]);
|
updateConfig("options", defaultOptionSets[setName]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 선택된 관계 정보
|
||||||
|
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -238,23 +315,122 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본 옵션 세트 */}
|
{/* 연쇄 드롭다운 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
|
<Link2 className="h-4 w-4" />
|
||||||
예/아니오
|
<h4 className="text-sm font-medium">연쇄 드롭다운</h4>
|
||||||
</Button>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
|
<Switch
|
||||||
상태
|
checked={cascadingEnabled}
|
||||||
</Button>
|
onCheckedChange={handleCascadingToggle}
|
||||||
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
|
/>
|
||||||
우선순위
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
다른 필드의 값에 따라 옵션이 동적으로 변경됩니다. (예: 창고 선택 → 해당 창고의 위치만 표시)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cascadingEnabled && (
|
||||||
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
|
{/* 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">연쇄 관계 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedRelationCode}
|
||||||
|
onValueChange={handleRelationSelect}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-xs">
|
||||||
|
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relationList.map((relation) => (
|
||||||
|
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{relation.relation_name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{relation.parent_table} → {relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
미리 정의된 관계를 선택하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부모 필드 설정 */}
|
||||||
|
{selectedRelationCode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">부모 필드 (화면 내 필드명)</Label>
|
||||||
|
<Input
|
||||||
|
value={selectedParentField}
|
||||||
|
onChange={(e) => handleParentFieldChange(e.target.value)}
|
||||||
|
placeholder="예: warehouse_code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
이 드롭다운의 옵션을 결정할 부모 필드의 컬럼명을 입력하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 관계 정보 표시 */}
|
||||||
|
{selectedRelation && (
|
||||||
|
<div className="bg-muted/50 space-y-2 rounded-md p-2">
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">부모 테이블:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_table}</span>
|
||||||
|
<span className="text-muted-foreground"> ({selectedRelation.parent_value_column})</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">자식 테이블:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_table}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}({selectedRelation.child_filter_column} → {selectedRelation.child_value_column})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedRelation.description && (
|
||||||
|
<div className="text-muted-foreground text-xs">{selectedRelation.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관계 관리 페이지 링크 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/admin/cascading-relations" target="_blank">
|
||||||
|
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
|
관계 관리
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 옵션 관리 */}
|
{/* 기본 옵션 세트 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
|
||||||
|
예/아니오
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
|
||||||
|
상태
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
|
||||||
|
우선순위
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 옵션 관리 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">옵션 관리</h4>
|
<h4 className="text-sm font-medium">옵션 관리</h4>
|
||||||
|
|
||||||
|
|
@ -337,8 +513,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 기본값 설정 */}
|
{/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">기본값</h4>
|
<h4 className="text-sm font-medium">기본값</h4>
|
||||||
|
|
||||||
|
|
@ -361,6 +539,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 상태 설정 */}
|
{/* 상태 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -395,7 +574,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">미리보기</h4>
|
<h4 className="text-sm font-medium">미리보기</h4>
|
||||||
<div className="bg-muted/50 rounded-md border p-3">
|
<div className="bg-muted/50 rounded-md border p-3">
|
||||||
|
|
@ -422,11 +602,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SelectConfigPanel.displayName = "SelectConfigPanel";
|
SelectConfigPanel.displayName = "SelectConfigPanel";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1179,6 +1179,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
return currentTable?.columns || [];
|
return currentTable?.columns || [];
|
||||||
})()}
|
})()}
|
||||||
tables={tables} // 전체 테이블 목록 전달
|
tables={tables} // 전체 테이블 목록 전달
|
||||||
|
allComponents={components} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1074,6 +1076,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
Object.entries(newConfig).forEach(([key, value]) => {
|
Object.entries(newConfig).forEach(([key, value]) => {
|
||||||
|
|
@ -1237,6 +1241,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||||
// 전체 componentConfig를 업데이트
|
// 전체 componentConfig를 업데이트
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운(Cascading Dropdown) 훅
|
||||||
|
*
|
||||||
|
* 부모 필드의 값에 따라 자식 드롭다운의 옵션을 동적으로 로드합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 방법 1: 관계 코드 사용 (권장)
|
||||||
|
* const { options, loading, error } = useCascadingDropdown({
|
||||||
|
* relationCode: "WAREHOUSE_LOCATION",
|
||||||
|
* parentValue: formData.warehouse_code,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 방법 2: 직접 설정 (레거시)
|
||||||
|
* const { options, loading, error } = useCascadingDropdown({
|
||||||
|
* config: {
|
||||||
|
* enabled: true,
|
||||||
|
* parentField: "warehouse_code",
|
||||||
|
* sourceTable: "warehouse_location",
|
||||||
|
* parentKeyColumn: "warehouse_id",
|
||||||
|
* valueColumn: "location_code",
|
||||||
|
* labelColumn: "location_name",
|
||||||
|
* },
|
||||||
|
* parentValue: formData.warehouse_code,
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
export interface CascadingOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
[key: string]: any; // 추가 데이터
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCascadingDropdownProps {
|
||||||
|
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
|
relationCode?: string;
|
||||||
|
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
||||||
|
role?: "parent" | "child";
|
||||||
|
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
||||||
|
config?: CascadingDropdownConfig;
|
||||||
|
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
||||||
|
initialOptions?: CascadingOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCascadingDropdownResult {
|
||||||
|
/** 드롭다운 옵션 목록 */
|
||||||
|
options: CascadingOption[];
|
||||||
|
/** 로딩 상태 */
|
||||||
|
loading: boolean;
|
||||||
|
/** 에러 메시지 */
|
||||||
|
error: string | null;
|
||||||
|
/** 옵션 새로고침 */
|
||||||
|
refresh: () => void;
|
||||||
|
/** 옵션 초기화 */
|
||||||
|
clear: () => void;
|
||||||
|
/** 특정 값의 라벨 가져오기 */
|
||||||
|
getLabelByValue: (value: string) => string | undefined;
|
||||||
|
/** API에서 가져온 관계 설정 (relationCode 사용 시) */
|
||||||
|
relationConfig: CascadingDropdownConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 글로벌 캐시 (컴포넌트 간 공유)
|
||||||
|
const optionsCache = new Map<string, { options: CascadingOption[]; timestamp: number }>();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
export function useCascadingDropdown({
|
||||||
|
relationCode,
|
||||||
|
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
initialOptions = [],
|
||||||
|
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
||||||
|
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [relationConfig, setRelationConfig] = useState<CascadingDropdownConfig | null>(null);
|
||||||
|
|
||||||
|
// 이전 부모 값 추적 (변경 감지용)
|
||||||
|
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// 관계 코드 또는 직접 설정 중 하나라도 있는지 확인
|
||||||
|
const isEnabled = !!relationCode || config?.enabled;
|
||||||
|
|
||||||
|
// 캐시 키 생성
|
||||||
|
const getCacheKey = useCallback(() => {
|
||||||
|
if (relationCode) {
|
||||||
|
// 부모 역할: 전체 옵션 캐시
|
||||||
|
if (role === "parent") {
|
||||||
|
return `relation:${relationCode}:parent:all`;
|
||||||
|
}
|
||||||
|
// 자식 역할: 부모 값별 캐시
|
||||||
|
if (!parentValue) return null;
|
||||||
|
return `relation:${relationCode}:child:${parentValue}`;
|
||||||
|
}
|
||||||
|
if (config) {
|
||||||
|
if (!parentValue) return null;
|
||||||
|
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [relationCode, role, config, parentValue]);
|
||||||
|
|
||||||
|
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
||||||
|
const loadParentOptions = useCallback(async () => {
|
||||||
|
if (!relationCode) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = getCacheKey();
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
if (cacheKey) {
|
||||||
|
const cached = optionsCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
setOptions(cached.options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 부모 역할용 API 호출 (전체 옵션)
|
||||||
|
const response = await apiClient.get(`/cascading-relations/parent-options/${relationCode}`);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||||
|
setOptions(loadedOptions);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
if (cacheKey) {
|
||||||
|
optionsCache.set(cacheKey, {
|
||||||
|
options: loadedOptions,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Parent options 로드 완료:", {
|
||||||
|
relationCode,
|
||||||
|
count: loadedOptions.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ Parent options 로드 실패:", err);
|
||||||
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
||||||
|
setOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [relationCode, getCacheKey]);
|
||||||
|
|
||||||
|
// 자식 역할 옵션 로드 (관계 코드 방식)
|
||||||
|
const loadChildOptions = useCallback(async () => {
|
||||||
|
if (!relationCode || !parentValue) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = getCacheKey();
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
if (cacheKey) {
|
||||||
|
const cached = optionsCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
setOptions(cached.options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션)
|
||||||
|
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||||
|
setOptions(loadedOptions);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
if (cacheKey) {
|
||||||
|
optionsCache.set(cacheKey, {
|
||||||
|
options: loadedOptions,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Child options 로드 완료:", {
|
||||||
|
relationCode,
|
||||||
|
parentValue,
|
||||||
|
count: loadedOptions.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ Child options 로드 실패:", err);
|
||||||
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
||||||
|
setOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [relationCode, parentValue, getCacheKey]);
|
||||||
|
|
||||||
|
// 옵션 로드 (직접 설정 방식 - 레거시)
|
||||||
|
const loadOptionsByConfig = useCallback(async () => {
|
||||||
|
if (!config?.enabled || !parentValue) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = getCacheKey();
|
||||||
|
|
||||||
|
// 캐시 확인
|
||||||
|
if (cacheKey) {
|
||||||
|
const cached = optionsCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
setOptions(cached.options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 호출하여 옵션 로드
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${config.sourceTable}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000, // 충분히 큰 값
|
||||||
|
search: {
|
||||||
|
[config.parentKeyColumn]: parentValue,
|
||||||
|
...config.additionalFilters,
|
||||||
|
},
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = response.data?.data?.data || response.data?.data || [];
|
||||||
|
|
||||||
|
const loadedOptions: CascadingOption[] = items.map((item: any) => ({
|
||||||
|
value: String(item[config.valueColumn] || ""),
|
||||||
|
label: String(item[config.labelColumn] || item[config.valueColumn] || ""),
|
||||||
|
...item, // 전체 데이터 보존
|
||||||
|
}));
|
||||||
|
|
||||||
|
setOptions(loadedOptions);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
if (cacheKey) {
|
||||||
|
optionsCache.set(cacheKey, {
|
||||||
|
options: loadedOptions,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Cascading options 로드 완료 (직접설정):", {
|
||||||
|
sourceTable: config.sourceTable,
|
||||||
|
parentValue,
|
||||||
|
count: loadedOptions.length,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ Cascading options 로드 실패:", err);
|
||||||
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
||||||
|
setOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [config, parentValue, getCacheKey]);
|
||||||
|
|
||||||
|
// 통합 로드 함수
|
||||||
|
const loadOptions = useCallback(() => {
|
||||||
|
if (relationCode) {
|
||||||
|
// 역할에 따라 다른 로드 함수 호출
|
||||||
|
if (role === "parent") {
|
||||||
|
loadParentOptions();
|
||||||
|
} else {
|
||||||
|
loadChildOptions();
|
||||||
|
}
|
||||||
|
} else if (config?.enabled) {
|
||||||
|
loadOptionsByConfig();
|
||||||
|
} else {
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||||
|
|
||||||
|
// 옵션 로드 트리거
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 역할: 즉시 전체 옵션 로드
|
||||||
|
if (role === "parent") {
|
||||||
|
loadOptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 역할: 부모 값이 있을 때만 로드
|
||||||
|
// 부모 값이 변경되었는지 확인
|
||||||
|
const parentChanged = prevParentValueRef.current !== parentValue;
|
||||||
|
prevParentValueRef.current = parentValue;
|
||||||
|
|
||||||
|
if (parentValue) {
|
||||||
|
loadOptions();
|
||||||
|
} else {
|
||||||
|
// 부모 값이 없으면 옵션 초기화
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
}, [isEnabled, role, parentValue, loadOptions]);
|
||||||
|
|
||||||
|
// 옵션 새로고침
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
const cacheKey = getCacheKey();
|
||||||
|
if (cacheKey) {
|
||||||
|
optionsCache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
loadOptions();
|
||||||
|
}, [getCacheKey, loadOptions]);
|
||||||
|
|
||||||
|
// 옵션 초기화
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setOptions([]);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 값으로 라벨 찾기
|
||||||
|
const getLabelByValue = useCallback((value: string): string | undefined => {
|
||||||
|
const option = options.find((opt) => opt.value === value);
|
||||||
|
return option?.label;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
clear,
|
||||||
|
getLabelByValue,
|
||||||
|
relationConfig: relationConfig || config || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시 관리 유틸리티
|
||||||
|
export const cascadingDropdownCache = {
|
||||||
|
/** 특정 테이블의 캐시 삭제 */
|
||||||
|
invalidateTable: (tableName: string) => {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
optionsCache.forEach((_, key) => {
|
||||||
|
if (key.startsWith(`${tableName}:`)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
keysToDelete.forEach((key) => optionsCache.delete(key));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 모든 캐시 삭제 */
|
||||||
|
invalidateAll: () => {
|
||||||
|
optionsCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 캐시 상태 확인 */
|
||||||
|
getStats: () => ({
|
||||||
|
size: optionsCache.size,
|
||||||
|
keys: Array.from(optionsCache.keys()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCascadingDropdown;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface CascadingRelation {
|
||||||
|
relation_id: number;
|
||||||
|
relation_code: string;
|
||||||
|
relation_name: string;
|
||||||
|
description?: string;
|
||||||
|
parent_table: string;
|
||||||
|
parent_value_column: string;
|
||||||
|
parent_label_column?: string;
|
||||||
|
child_table: string;
|
||||||
|
child_filter_column: string;
|
||||||
|
child_value_column: string;
|
||||||
|
child_label_column: string;
|
||||||
|
child_order_column?: string;
|
||||||
|
child_order_direction?: string;
|
||||||
|
empty_parent_message?: string;
|
||||||
|
no_options_message?: string;
|
||||||
|
loading_message?: string;
|
||||||
|
clear_on_parent_change?: string;
|
||||||
|
company_code: string;
|
||||||
|
is_active?: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_date?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
updated_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CascadingRelationCreateInput {
|
||||||
|
relationCode: string;
|
||||||
|
relationName: string;
|
||||||
|
description?: string;
|
||||||
|
parentTable: string;
|
||||||
|
parentValueColumn: string;
|
||||||
|
parentLabelColumn?: string;
|
||||||
|
childTable: string;
|
||||||
|
childFilterColumn: string;
|
||||||
|
childValueColumn: string;
|
||||||
|
childLabelColumn: string;
|
||||||
|
childOrderColumn?: string;
|
||||||
|
childOrderDirection?: string;
|
||||||
|
emptyParentMessage?: string;
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
clearOnParentChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CascadingRelationUpdateInput extends Partial<CascadingRelationCreateInput> {
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CascadingOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 목록 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelations = async (isActive?: string) => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
params.append("isActive", isActive);
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/cascading-relations?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 목록 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 상세 조회 (ID)
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationById = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-relations/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 상세 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 코드로 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationByCode = async (code: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-relations/code/${code}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 코드 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingOptions = async (code: string, parentValue: string): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 옵션 조회 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 생성
|
||||||
|
*/
|
||||||
|
export const createCascadingRelation = async (data: CascadingRelationCreateInput) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-relations", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 생성 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 수정
|
||||||
|
*/
|
||||||
|
export const updateCascadingRelation = async (id: number, data: CascadingRelationUpdateInput) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-relations/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 수정 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCascadingRelation = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-relations/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("연쇄 관계 삭제 실패:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cascadingRelationApi = {
|
||||||
|
getList: getCascadingRelations,
|
||||||
|
getById: getCascadingRelationById,
|
||||||
|
getByCode: getCascadingRelationByCode,
|
||||||
|
getOptions: getCascadingOptions,
|
||||||
|
create: createCascadingRelation,
|
||||||
|
update: updateCascadingRelation,
|
||||||
|
delete: deleteCascadingRelation,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import type { DataProvidable } from "@/types/data-transfer";
|
import type { DataProvidable } from "@/types/data-transfer";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -26,6 +27,7 @@ export interface SelectBasicComponentProps {
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
value?: any; // 외부에서 전달받는 값
|
value?: any; // 외부에서 전달받는 값
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||||
|
formData?: Record<string, any>; // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +52,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
value: externalValue, // 명시적으로 value prop 받기
|
value: externalValue, // 명시적으로 value prop 받기
|
||||||
menuObjid, // 🆕 메뉴 OBJID
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
|
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 읽기전용/비활성화 상태 확인
|
// 🆕 읽기전용/비활성화 상태 확인
|
||||||
|
|
@ -151,6 +154,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
||||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정 확인
|
||||||
|
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
||||||
|
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
||||||
|
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
||||||
|
// 자식 역할일 때만 부모 값 필요
|
||||||
|
const parentValue = cascadingRole === "child" && cascadingParentField && formData
|
||||||
|
? formData[cascadingParentField]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드)
|
||||||
|
const {
|
||||||
|
options: cascadingOptions,
|
||||||
|
loading: isLoadingCascading,
|
||||||
|
} = useCascadingDropdown({
|
||||||
|
relationCode: cascadingRelationCode,
|
||||||
|
role: cascadingRole, // 부모/자식 역할 전달
|
||||||
|
parentValue: parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webType === "category" && component.tableName && component.columnName) {
|
if (webType === "category" && component.tableName && component.columnName) {
|
||||||
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||||
|
|
@ -301,12 +323,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 선택된 값에 따른 라벨 업데이트
|
// 선택된 값에 따른 라벨 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllOptions = () => {
|
const getAllOptionsForLabel = () => {
|
||||||
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
|
if (cascadingRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = getAllOptions();
|
const options = getAllOptionsForLabel();
|
||||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||||
|
|
||||||
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
||||||
|
|
@ -327,7 +353,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
if (newLabel !== selectedLabel) {
|
if (newLabel !== selectedLabel) {
|
||||||
setSelectedLabel(newLabel);
|
setSelectedLabel(newLabel);
|
||||||
}
|
}
|
||||||
}, [selectedValue, codeOptions, config.options]);
|
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
|
@ -378,6 +404,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
|
if (cascadingRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
|
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link2, ExternalLink } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { SelectBasicConfig } from "./types";
|
import { SelectBasicConfig } from "./types";
|
||||||
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
export interface SelectBasicConfigPanelProps {
|
export interface SelectBasicConfigPanelProps {
|
||||||
config: SelectBasicConfig;
|
config: SelectBasicConfig;
|
||||||
onChange: (config: Partial<SelectBasicConfig>) => void;
|
onChange: (config: Partial<SelectBasicConfig>) => void;
|
||||||
|
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
|
||||||
|
allComponents?: any[];
|
||||||
|
/** 현재 컴포넌트 정보 */
|
||||||
|
currentComponent?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,20 +28,134 @@ export interface SelectBasicConfigPanelProps {
|
||||||
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
allComponents = [],
|
||||||
|
currentComponent,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 연쇄 드롭다운 관련 상태
|
||||||
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (cascadingEnabled && relationList.length === 0) {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// config 변경 시 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||||
|
}, [config.cascadingRelationCode]);
|
||||||
|
|
||||||
|
const loadRelationList = async () => {
|
||||||
|
setLoadingRelations(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelationList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||||
const newConfig = { ...config, [key]: value };
|
const newConfig = { ...config, [key]: value };
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 토글
|
||||||
|
const handleCascadingToggle = (enabled: boolean) => {
|
||||||
|
setCascadingEnabled(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
// 비활성화 시 관계 설정 제거
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
cascadingRelationCode: undefined,
|
||||||
|
cascadingRole: undefined,
|
||||||
|
cascadingParentField: undefined,
|
||||||
|
};
|
||||||
|
onChange(newConfig);
|
||||||
|
} else {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 같은 연쇄 관계의 부모 역할 컴포넌트 찾기
|
||||||
|
const findParentComponent = (relationCode: string) => {
|
||||||
|
console.log("🔍 findParentComponent 호출:", {
|
||||||
|
relationCode,
|
||||||
|
allComponentsLength: allComponents?.length,
|
||||||
|
currentComponentId: currentComponent?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allComponents || allComponents.length === 0) {
|
||||||
|
console.log("❌ allComponents가 비어있음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 컴포넌트의 cascading 설정 확인
|
||||||
|
allComponents.forEach((comp: any) => {
|
||||||
|
const compConfig = comp.componentConfig || {};
|
||||||
|
if (compConfig.cascadingRelationCode) {
|
||||||
|
console.log("📦 컴포넌트 cascading 설정:", {
|
||||||
|
id: comp.id,
|
||||||
|
columnName: comp.columnName,
|
||||||
|
cascadingRelationCode: compConfig.cascadingRelationCode,
|
||||||
|
cascadingRole: compConfig.cascadingRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = allComponents.find((comp: any) => {
|
||||||
|
const compConfig = comp.componentConfig || {};
|
||||||
|
return (
|
||||||
|
comp.id !== currentComponent?.id && // 자기 자신 제외
|
||||||
|
compConfig.cascadingRelationCode === relationCode &&
|
||||||
|
compConfig.cascadingRole === "parent"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 찾은 부모 컴포넌트:", found);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 역할 변경 시 부모 필드 자동 감지
|
||||||
|
const handleRoleChange = (role: "parent" | "child") => {
|
||||||
|
let parentField = config.cascadingParentField;
|
||||||
|
|
||||||
|
// 자식 역할 선택 시 부모 필드 자동 감지
|
||||||
|
if (role === "child" && config.cascadingRelationCode) {
|
||||||
|
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||||
|
if (parentComp) {
|
||||||
|
parentField = parentComp.columnName;
|
||||||
|
console.log("🔗 부모 필드 자동 감지:", parentField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
cascadingRole: role,
|
||||||
|
// 부모 역할일 때는 부모 필드 불필요, 자식일 때는 자동 감지된 값 또는 기존 값
|
||||||
|
cascadingParentField: role === "parent" ? undefined : parentField,
|
||||||
|
};
|
||||||
|
onChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 관계 정보
|
||||||
|
const selectedRelation = relationList.find(r => r.relation_code === config.cascadingRelationCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
select-basic 설정
|
select-basic 설정
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* select 관련 설정 */}
|
{/* select 관련 설정 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -78,6 +201,179 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 연쇄 드롭다운 설정 */}
|
||||||
|
<div className="border-t pt-4 mt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<Label className="text-sm font-medium">연쇄 드롭다운</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cascadingEnabled}
|
||||||
|
onCheckedChange={handleCascadingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
다른 필드의 값에 따라 옵션이 동적으로 변경됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cascadingEnabled && (
|
||||||
|
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
|
||||||
|
{/* 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">연쇄 관계 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cascadingRelationCode || ""}
|
||||||
|
onValueChange={(value) => handleChange("cascadingRelationCode", value || undefined)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-xs">
|
||||||
|
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relationList.map((relation) => (
|
||||||
|
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{relation.relation_name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{relation.parent_table} → {relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 역할 선택 */}
|
||||||
|
{config.cascadingRelationCode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">역할 선택</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={config.cascadingRole === "parent" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("parent")}
|
||||||
|
>
|
||||||
|
부모 (상위 선택)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={config.cascadingRole === "child" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("child")}
|
||||||
|
>
|
||||||
|
자식 (하위 선택)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{config.cascadingRole === "parent"
|
||||||
|
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
|
||||||
|
: config.cascadingRole === "child"
|
||||||
|
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
|
||||||
|
: "이 필드의 역할을 선택하세요."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||||
|
{config.cascadingRelationCode && config.cascadingRole === "child" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">부모 필드명</Label>
|
||||||
|
{(() => {
|
||||||
|
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||||
|
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
value={config.cascadingParentField || ""}
|
||||||
|
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
|
||||||
|
placeholder="예: warehouse_code"
|
||||||
|
className="text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{parentComp && !isAutoDetected && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
|
||||||
|
>
|
||||||
|
자동감지
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAutoDetected ? (
|
||||||
|
<p className="text-xs text-green-600">
|
||||||
|
자동 감지됨: {parentComp.label || parentComp.columnName}
|
||||||
|
</p>
|
||||||
|
) : parentComp ? (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
감지된 부모 필드: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
같은 관계의 부모 역할 필드가 없습니다. 수동으로 입력하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 관계 정보 표시 */}
|
||||||
|
{selectedRelation && config.cascadingRole && (
|
||||||
|
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
|
||||||
|
{config.cascadingRole === "parent" ? (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-blue-600">부모 역할 (상위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_value_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-green-600">자식 역할 (하위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">필터 기준:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_filter_column}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_value_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관계 관리 페이지 링크 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/admin/cascading-relations" target="_blank">
|
||||||
|
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
|
관계 관리
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface SelectBasicConfig extends ComponentConfig {
|
||||||
// 코드 관련 설정
|
// 코드 관련 설정
|
||||||
codeCategory?: string;
|
codeCategory?: string;
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정
|
||||||
|
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
|
cascadingRelationCode?: string;
|
||||||
|
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
|
||||||
|
cascadingRole?: "parent" | "child";
|
||||||
|
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
// 공통 설정
|
// 공통 설정
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react";
|
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { generateNumberingCode } from "@/lib/api/numberingRule";
|
import { generateNumberingCode } from "@/lib/api/numberingRule";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UniversalFormModalComponentProps,
|
UniversalFormModalComponentProps,
|
||||||
|
|
@ -36,6 +38,83 @@ import {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig, generateUniqueId } from "./config";
|
import { defaultConfig, generateUniqueId } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||||
|
*/
|
||||||
|
interface CascadingSelectFieldProps {
|
||||||
|
fieldId: string;
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
fieldId,
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const { options, loading } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={fieldId} className="w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 범용 폼 모달 컴포넌트
|
* 범용 폼 모달 컴포넌트
|
||||||
*
|
*
|
||||||
|
|
@ -837,6 +916,24 @@ export function UniversalFormModalComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select": {
|
case "select": {
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
if (field.cascading?.enabled) {
|
||||||
|
const cascadingConfig = field.cascading;
|
||||||
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascadingSelectField
|
||||||
|
fieldId={fieldKey}
|
||||||
|
config={cascadingConfig as CascadingDropdownConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={value}
|
||||||
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 다중 컬럼 저장이 활성화된 경우
|
// 다중 컬럼 저장이 활성화된 경우
|
||||||
const lfgMappings = field.linkedFieldGroup?.mappings;
|
const lfgMappings = field.linkedFieldGroup?.mappings;
|
||||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,21 @@ export interface FormFieldConfig {
|
||||||
action: "filter" | "setValue" | "clear";
|
action: "filter" | "setValue" | "clear";
|
||||||
config?: any;
|
config?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정 (부모 필드에 따른 동적 옵션)
|
||||||
|
cascading?: {
|
||||||
|
enabled: boolean;
|
||||||
|
parentField: string; // 부모 필드명
|
||||||
|
sourceTable: string; // 옵션을 조회할 테이블
|
||||||
|
parentKeyColumn: string; // 부모 값과 매칭할 컬럼
|
||||||
|
valueColumn: string; // 드롭다운 value로 사용할 컬럼
|
||||||
|
labelColumn: string; // 드롭다운 label로 표시할 컬럼
|
||||||
|
additionalFilters?: Record<string, unknown>;
|
||||||
|
emptyParentMessage?: string;
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
clearOnParentChange?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 연동 필드 매핑 설정
|
// 연동 필드 매핑 설정
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ export interface ComponentConfigPanelProps {
|
||||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||||
tables?: any[]; // 전체 테이블 목록
|
tables?: any[]; // 전체 테이블 목록
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
||||||
|
allComponents?: any[]; // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||||
|
currentComponent?: any; // 🆕 현재 컴포넌트 정보
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||||
|
|
@ -143,6 +145,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
tableColumns,
|
tableColumns,
|
||||||
tables,
|
tables,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
allComponents,
|
||||||
|
currentComponent,
|
||||||
}) => {
|
}) => {
|
||||||
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
||||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||||
|
|
@ -432,6 +436,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
||||||
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||||
|
currentComponent={currentComponent} // 🆕 현재 컴포넌트 정보
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -267,11 +267,24 @@ export interface NumberTypeConfig {
|
||||||
* 선택박스 타입 설정
|
* 선택박스 타입 설정
|
||||||
*/
|
*/
|
||||||
export interface SelectTypeConfig {
|
export interface SelectTypeConfig {
|
||||||
options: Array<{ label: string; value: string }>;
|
options: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
|
||||||
|
/** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
|
cascadingRelationCode?: string;
|
||||||
|
|
||||||
|
/** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
|
/** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */
|
||||||
|
cascading?: CascadingDropdownConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -352,6 +365,58 @@ export interface EntityTypeConfig {
|
||||||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 연쇄 드롭다운(Cascading Dropdown) 설정
|
||||||
|
*
|
||||||
|
* 부모 필드의 값에 따라 자식 드롭다운의 옵션이 동적으로 변경됩니다.
|
||||||
|
* 예: 창고 선택 → 해당 창고의 위치만 표시
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 창고 → 위치 연쇄 드롭다운
|
||||||
|
* {
|
||||||
|
* enabled: true,
|
||||||
|
* parentField: "warehouse_code",
|
||||||
|
* sourceTable: "warehouse_location",
|
||||||
|
* parentKeyColumn: "warehouse_id",
|
||||||
|
* valueColumn: "location_code",
|
||||||
|
* labelColumn: "location_name",
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface CascadingDropdownConfig {
|
||||||
|
/** 연쇄 드롭다운 활성화 여부 */
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */
|
||||||
|
parentField: string;
|
||||||
|
|
||||||
|
/** 옵션을 조회할 테이블명 */
|
||||||
|
sourceTable: string;
|
||||||
|
|
||||||
|
/** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */
|
||||||
|
parentKeyColumn: string;
|
||||||
|
|
||||||
|
/** 드롭다운 value로 사용할 컬럼명 */
|
||||||
|
valueColumn: string;
|
||||||
|
|
||||||
|
/** 드롭다운 label로 표시할 컬럼명 */
|
||||||
|
labelColumn: string;
|
||||||
|
|
||||||
|
/** 추가 필터 조건 (선택사항) */
|
||||||
|
additionalFilters?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 부모 값이 없을 때 표시할 메시지 */
|
||||||
|
emptyParentMessage?: string;
|
||||||
|
|
||||||
|
/** 옵션이 없을 때 표시할 메시지 */
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
|
||||||
|
/** 로딩 중 표시할 메시지 */
|
||||||
|
loadingMessage?: string;
|
||||||
|
|
||||||
|
/** 부모 값 변경 시 자동으로 값 초기화 */
|
||||||
|
clearOnParentChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 타입 설정
|
* 버튼 타입 설정
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue