연쇄관계 관리
This commit is contained in:
parent
ba817980f0
commit
c71b958a05
|
|
@ -76,6 +76,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
|||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -247,6 +248,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
|||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// 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 { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||
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와 호환)
|
||||
interface FileInfo {
|
||||
|
|
@ -1434,6 +1511,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "select":
|
||||
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 || [];
|
||||
if (options.length > 0) {
|
||||
|
|
@ -1670,9 +1766,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "select":
|
||||
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 || [];
|
||||
if (options.length > 0) {
|
||||
const optionsAdd = detailSettings?.options || [];
|
||||
if (optionsAdd.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||
|
|
@ -1680,7 +1795,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<SelectValue placeholder={commonProps.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option: any, index: number) => (
|
||||
{optionsAdd.map((option: any, index: number) => (
|
||||
<SelectItem key={index} value={option.value || option}>
|
||||
{option.label || option}
|
||||
</SelectItem>
|
||||
|
|
@ -1696,20 +1811,20 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "radio":
|
||||
// 상세 설정에서 옵션 목록 가져오기
|
||||
const radioOptions = detailSettings?.options || [];
|
||||
const defaultValue = detailSettings?.defaultValue;
|
||||
const radioOptionsAdd = detailSettings?.options || [];
|
||||
const defaultValueAdd = detailSettings?.defaultValue;
|
||||
|
||||
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
||||
if (radioOptions.length > 0) {
|
||||
if (radioOptionsAdd.length > 0) {
|
||||
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
||||
if (!value && defaultValue) {
|
||||
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
|
||||
if (!value && defaultValueAdd) {
|
||||
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
<input
|
||||
type="radio"
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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 { ko } from "date-fns/locale";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
|
|
@ -49,6 +51,96 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
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 {
|
||||
component: ComponentData;
|
||||
allComponents: ComponentData[];
|
||||
|
|
@ -697,10 +789,55 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
searchable: config?.searchable,
|
||||
placeholder: config?.placeholder,
|
||||
defaultValue: config?.defaultValue,
|
||||
cascading: config?.cascading,
|
||||
},
|
||||
});
|
||||
|
||||
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 || [
|
||||
{ label: "옵션 1", value: "option1" },
|
||||
{ label: "옵션 2", value: "option2" },
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import { Label } from "@/components/ui/label";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
|
|
@ -38,7 +41,18 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
required: config.required || false,
|
||||
readonly: config.readonly || false,
|
||||
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("");
|
||||
|
|
@ -66,6 +80,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||
cascadingRelationCode: currentConfig.cascadingRelationCode,
|
||||
});
|
||||
|
||||
// 입력 필드 로컬 상태도 동기화
|
||||
|
|
@ -73,7 +88,34 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder: currentConfig.placeholder || "",
|
||||
emptyMessage: currentConfig.emptyMessage || "",
|
||||
});
|
||||
|
||||
// 연쇄 드롭다운 설정 동기화
|
||||
setCascadingEnabled(!!currentConfig.cascadingRelationCode);
|
||||
setSelectedRelationCode(currentConfig.cascadingRelationCode || "");
|
||||
setSelectedParentField(currentConfig.cascadingParentField || "");
|
||||
}, [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) => {
|
||||
|
|
@ -82,6 +124,38 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
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 = () => {
|
||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||
|
|
@ -167,6 +241,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
updateConfig("options", defaultOptionSets[setName]);
|
||||
};
|
||||
|
||||
// 선택된 관계 정보
|
||||
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -238,23 +315,122 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 옵션 세트 */}
|
||||
{/* 연쇄 드롭다운 설정 */}
|
||||
<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 className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<h4 className="text-sm font-medium">연쇄 드롭다운</h4>
|
||||
</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">
|
||||
{/* 관계 선택 */}
|
||||
<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>
|
||||
|
||||
{/* 옵션 관리 */}
|
||||
{/* 기본 옵션 세트 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||
{!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">
|
||||
<h4 className="text-sm font-medium">옵션 관리</h4>
|
||||
|
||||
|
|
@ -337,8 +513,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
{/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||
{!cascadingEnabled && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">기본값</h4>
|
||||
|
||||
|
|
@ -361,6 +539,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<div className="space-y-3">
|
||||
|
|
@ -395,7 +574,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||
{!cascadingEnabled && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">미리보기</h4>
|
||||
<div className="bg-muted/50 rounded-md border p-3">
|
||||
|
|
@ -422,11 +602,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
SelectConfigPanel.displayName = "SelectConfigPanel";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
return currentTable?.columns || [];
|
||||
})()}
|
||||
tables={tables} // 전체 테이블 목록 전달
|
||||
allComponents={components} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
onChange={(newConfig) => {
|
||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
|
|
|
|||
|
|
@ -366,6 +366,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
@ -1074,6 +1076,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
onChange={(newConfig) => {
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
|
|
@ -1237,6 +1241,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
onChange={(newConfig) => {
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||
// 전체 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 { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
|
|
@ -26,6 +27,7 @@ export interface SelectBasicComponentProps {
|
|||
onDragEnd?: () => void;
|
||||
value?: any; // 외부에서 전달받는 값
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||
formData?: Record<string, any>; // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
onDragEnd,
|
||||
value: externalValue, // 명시적으로 value prop 받기
|
||||
menuObjid, // 🆕 메뉴 OBJID
|
||||
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||
...props
|
||||
}) => {
|
||||
// 🆕 읽기전용/비활성화 상태 확인
|
||||
|
|
@ -151,6 +154,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
||||
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(() => {
|
||||
if (webType === "category" && component.tableName && component.columnName) {
|
||||
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||
|
|
@ -301,12 +323,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
// 선택된 값에 따른 라벨 업데이트
|
||||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
const getAllOptionsForLabel = () => {
|
||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||
if (cascadingRelationCode) {
|
||||
return cascadingOptions;
|
||||
}
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
};
|
||||
|
||||
const options = getAllOptions();
|
||||
const options = getAllOptionsForLabel();
|
||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||
|
||||
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
||||
|
|
@ -327,7 +353,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
if (newLabel !== selectedLabel) {
|
||||
setSelectedLabel(newLabel);
|
||||
}
|
||||
}, [selectedValue, codeOptions, config.options]);
|
||||
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
|
||||
|
||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||
const handleToggle = () => {
|
||||
|
|
@ -378,6 +404,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
// 모든 옵션 가져오기
|
||||
const getAllOptions = () => {
|
||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||
if (cascadingRelationCode) {
|
||||
return cascadingOptions;
|
||||
}
|
||||
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
|
||||
export interface SelectBasicConfigPanelProps {
|
||||
config: SelectBasicConfig;
|
||||
onChange: (config: Partial<SelectBasicConfig>) => void;
|
||||
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
|
||||
allComponents?: any[];
|
||||
/** 현재 컴포넌트 정보 */
|
||||
currentComponent?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,20 +28,134 @@ export interface SelectBasicConfigPanelProps {
|
|||
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||
config,
|
||||
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) => {
|
||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||
const newConfig = { ...config, [key]: value };
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">
|
||||
select-basic 설정
|
||||
</div>
|
||||
|
||||
{/* select 관련 설정 */}
|
||||
{/* select 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="placeholder">플레이스홀더</Label>
|
||||
<Input
|
||||
|
|
@ -78,6 +201,179 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ export interface SelectBasicConfig extends ComponentConfig {
|
|||
// 코드 관련 설정
|
||||
codeCategory?: string;
|
||||
|
||||
// 🆕 연쇄 드롭다운 설정
|
||||
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||
cascadingRelationCode?: string;
|
||||
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
|
||||
cascadingRole?: "parent" | "child";
|
||||
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
|
||||
cascadingParentField?: string;
|
||||
|
||||
// 공통 설정
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { generateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
import {
|
||||
UniversalFormModalComponentProps,
|
||||
|
|
@ -36,6 +38,83 @@ import {
|
|||
} from "./types";
|
||||
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": {
|
||||
// 🆕 연쇄 드롭다운 처리
|
||||
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;
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,21 @@ export interface FormFieldConfig {
|
|||
action: "filter" | "setValue" | "clear";
|
||||
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[]; // 테이블 컬럼 정보
|
||||
tables?: any[]; // 전체 테이블 목록
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
||||
allComponents?: any[]; // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||
currentComponent?: any; // 🆕 현재 컴포넌트 정보
|
||||
}
|
||||
|
||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||
|
|
@ -143,6 +145,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
tableColumns,
|
||||
tables,
|
||||
menuObjid,
|
||||
allComponents,
|
||||
currentComponent,
|
||||
}) => {
|
||||
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
||||
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만 전체 테이블
|
||||
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
allComponents={allComponents} // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||
currentComponent={currentComponent} // 🆕 현재 컴포넌트 정보
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -267,11 +267,24 @@ export interface NumberTypeConfig {
|
|||
* 선택박스 타입 설정
|
||||
*/
|
||||
export interface SelectTypeConfig {
|
||||
options: Array<{ label: string; value: string }>;
|
||||
options: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||
multiple?: boolean;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
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; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 연쇄 드롭다운(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