Compare commits
14 Commits
e84764dc2b
...
288e553221
| Author | SHA1 | Date |
|---|---|---|
|
|
288e553221 | |
|
|
5bbbd37553 | |
|
|
f272f0c4c7 | |
|
|
088596480f | |
|
|
3188bc0513 | |
|
|
08575c296e | |
|
|
c71b958a05 | |
|
|
ba817980f0 | |
|
|
1506389757 | |
|
|
ece7f21bd3 | |
|
|
1ee1287b8a | |
|
|
bb98e9319f | |
|
|
cf73ce6ebb | |
|
|
987120f13b |
|
|
@ -76,6 +76,11 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
|
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||||
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -247,6 +252,11 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
|
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||||
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -1256,8 +1256,17 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestCompanyCode =
|
let requestCompanyCode =
|
||||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
menuData.companyCode || menuData.company_code;
|
||||||
|
|
||||||
|
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||||
|
if (
|
||||||
|
requestCompanyCode === "none" ||
|
||||||
|
requestCompanyCode === "" ||
|
||||||
|
!requestCompanyCode
|
||||||
|
) {
|
||||||
|
requestCompanyCode = currentMenu.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
// company_code 변경 시도하는 경우 권한 체크
|
// company_code 변경 시도하는 경우 권한 체크
|
||||||
if (requestCompanyCode !== currentMenu.company_code) {
|
if (requestCompanyCode !== currentMenu.company_code) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,568 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 컨트롤러
|
||||||
|
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
g.*,
|
||||||
|
COUNT(m.mapping_id) as mapping_count
|
||||||
|
FROM cascading_auto_fill_group g
|
||||||
|
LEFT JOIN cascading_auto_fill_mapping m
|
||||||
|
ON g.group_code = m.group_code AND g.company_code = m.company_code
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_group
|
||||||
|
WHERE group_code = $1
|
||||||
|
`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupResult = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!groupResult) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order, mapping_id
|
||||||
|
`;
|
||||||
|
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...groupResult,
|
||||||
|
mappings: mappingResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "AF";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
mappings = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !masterTable || !masterValueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateAutoFillGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name, description,
|
||||||
|
master_table, master_value_column, master_label_column,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const groupResult = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 생성
|
||||||
|
if (mappings.length > 0) {
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 생성되었습니다.",
|
||||||
|
data: groupResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
mappings,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 업데이트
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_auto_fill_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
master_table = COALESCE($3, master_table),
|
||||||
|
master_value_column = COALESCE($4, master_value_column),
|
||||||
|
master_label_column = COALESCE($5, master_label_column),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $7 AND company_code = $8
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateResult = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 업데이트 (전체 교체 방식)
|
||||||
|
if (mappings !== undefined) {
|
||||||
|
// 기존 매핑 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
|
||||||
|
[groupCode, existing.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 매핑 추가
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 수정되었습니다.",
|
||||||
|
data: updateResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const deleteParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 옵션 목록 조회
|
||||||
|
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||||
|
*/
|
||||||
|
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 옵션 조회
|
||||||
|
const labelColumn = group.master_label_column || group.master_value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${group.master_value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
// company_code 컬럼 존재 여부 확인
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 데이터 조회
|
||||||
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const { masterValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!masterValue) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "masterValue 파라미터가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order
|
||||||
|
`;
|
||||||
|
const mappings = await query(mappingSql, [groupCode, group.company_code]);
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {},
|
||||||
|
mappings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 데이터 조회
|
||||||
|
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
|
||||||
|
let dataSql = `
|
||||||
|
SELECT ${sourceColumns}
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE ${group.master_value_column} = $1
|
||||||
|
`;
|
||||||
|
const dataParams: any[] = [masterValue];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
dataSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
dataParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataResult = await queryOne(dataSql, dataParams);
|
||||||
|
|
||||||
|
// 결과를 target_field 기준으로 변환
|
||||||
|
const autoFillData: Record<string, any> = {};
|
||||||
|
const mappingInfo: any[] = [];
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const sourceValue = dataResult?.[mapping.source_column];
|
||||||
|
const finalValue = sourceValue !== null && sourceValue !== undefined
|
||||||
|
? sourceValue
|
||||||
|
: mapping.default_value;
|
||||||
|
|
||||||
|
autoFillData[mapping.target_field] = finalValue;
|
||||||
|
mappingInfo.push({
|
||||||
|
targetField: mapping.target_field,
|
||||||
|
targetLabel: mapping.target_label,
|
||||||
|
value: finalValue,
|
||||||
|
isEditable: mapping.is_editable === "Y",
|
||||||
|
isRequired: mapping.is_required === "Y",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: autoFillData,
|
||||||
|
mappings: mappingInfo,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 데이터 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||||
|
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getConditions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, relationCode, relationType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드 필터
|
||||||
|
if (relationCode) {
|
||||||
|
sql += ` AND relation_code = $${paramIndex++}`;
|
||||||
|
params.push(relationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||||
|
if (relationType) {
|
||||||
|
sql += ` AND relation_type = $${paramIndex++}`;
|
||||||
|
params.push(relationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
|
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getConditionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const params: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
relationType = "RELATION",
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator = "EQ",
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority = 0,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_type, relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, priority,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
relationType,
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_condition SET
|
||||||
|
condition_name = COALESCE($1, condition_name),
|
||||||
|
condition_field = COALESCE($2, condition_field),
|
||||||
|
condition_operator = COALESCE($3, condition_operator),
|
||||||
|
condition_value = COALESCE($4, condition_value),
|
||||||
|
filter_column = COALESCE($5, filter_column),
|
||||||
|
filter_values = COALESCE($6, filter_values),
|
||||||
|
priority = COALESCE($7, priority),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE condition_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
Number(conditionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING condition_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
|
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getFilteredOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { relationCode } = req.params;
|
||||||
|
const { conditionFieldValue, parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 1. 기본 연쇄 관계 정보 조회
|
||||||
|
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||||
|
const relationParams: any[] = [relationCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationSql += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = await queryOne(relationSql, relationParams);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||||
|
let conditionSql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE relation_code = $1 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const conditionParams: any[] = [relationCode];
|
||||||
|
let conditionParamIndex = 2;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||||
|
conditionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditionSql += ` ORDER BY priority DESC`;
|
||||||
|
|
||||||
|
const conditions = await query(conditionSql, conditionParams);
|
||||||
|
|
||||||
|
// 3. 조건에 맞는 규칙 찾기
|
||||||
|
let matchedCondition: any = null;
|
||||||
|
|
||||||
|
if (conditionFieldValue) {
|
||||||
|
for (const cond of conditions) {
|
||||||
|
const isMatch = evaluateCondition(
|
||||||
|
conditionFieldValue as string,
|
||||||
|
cond.condition_operator,
|
||||||
|
cond.condition_value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matchedCondition = cond;
|
||||||
|
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 옵션 조회 쿼리 생성
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (기본 연쇄)
|
||||||
|
if (parentValue) {
|
||||||
|
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건부 필터 적용
|
||||||
|
if (matchedCondition) {
|
||||||
|
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
|
||||||
|
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||||
|
optionsParams.push(...filterValues);
|
||||||
|
optionsParamIndex += filterValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (relation.child_order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("조건부 필터링 옵션 조회", {
|
||||||
|
relationCode,
|
||||||
|
conditionFieldValue,
|
||||||
|
parentValue,
|
||||||
|
matchedCondition: matchedCondition?.condition_name,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
appliedCondition: matchedCondition
|
||||||
|
? {
|
||||||
|
conditionId: matchedCondition.condition_id,
|
||||||
|
conditionName: matchedCondition.condition_name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건 평가 함수
|
||||||
|
*/
|
||||||
|
function evaluateCondition(
|
||||||
|
actualValue: string,
|
||||||
|
operator: string,
|
||||||
|
expectedValue: string
|
||||||
|
): boolean {
|
||||||
|
const actual = actualValue.toLowerCase().trim();
|
||||||
|
const expected = expectedValue.toLowerCase().trim();
|
||||||
|
|
||||||
|
switch (operator.toUpperCase()) {
|
||||||
|
case "EQ":
|
||||||
|
case "=":
|
||||||
|
case "EQUALS":
|
||||||
|
return actual === expected;
|
||||||
|
|
||||||
|
case "NEQ":
|
||||||
|
case "!=":
|
||||||
|
case "<>":
|
||||||
|
case "NOT_EQUALS":
|
||||||
|
return actual !== expected;
|
||||||
|
|
||||||
|
case "CONTAINS":
|
||||||
|
case "LIKE":
|
||||||
|
return actual.includes(expected);
|
||||||
|
|
||||||
|
case "NOT_CONTAINS":
|
||||||
|
case "NOT_LIKE":
|
||||||
|
return !actual.includes(expected);
|
||||||
|
|
||||||
|
case "STARTS_WITH":
|
||||||
|
return actual.startsWith(expected);
|
||||||
|
|
||||||
|
case "ENDS_WITH":
|
||||||
|
return actual.endsWith(expected);
|
||||||
|
|
||||||
|
case "IN":
|
||||||
|
const inValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return inValues.includes(actual);
|
||||||
|
|
||||||
|
case "NOT_IN":
|
||||||
|
const notInValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return !notInValues.includes(actual);
|
||||||
|
|
||||||
|
case "GT":
|
||||||
|
case ">":
|
||||||
|
return parseFloat(actual) > parseFloat(expected);
|
||||||
|
|
||||||
|
case "GTE":
|
||||||
|
case ">=":
|
||||||
|
return parseFloat(actual) >= parseFloat(expected);
|
||||||
|
|
||||||
|
case "LT":
|
||||||
|
case "<":
|
||||||
|
return parseFloat(actual) < parseFloat(expected);
|
||||||
|
|
||||||
|
case "LTE":
|
||||||
|
case "<=":
|
||||||
|
return parseFloat(actual) <= parseFloat(expected);
|
||||||
|
|
||||||
|
case "IS_NULL":
|
||||||
|
case "NULL":
|
||||||
|
return actual === "" || actual === "null" || actual === "undefined";
|
||||||
|
|
||||||
|
case "IS_NOT_NULL":
|
||||||
|
case "NOT_NULL":
|
||||||
|
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,752 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 컨트롤러
|
||||||
|
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, hierarchyType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT g.*,
|
||||||
|
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
|
||||||
|
FROM cascading_hierarchy_group g
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hierarchyType) {
|
||||||
|
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
|
||||||
|
params.push(hierarchyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 조회
|
||||||
|
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
levelSql += ` ORDER BY level_order`;
|
||||||
|
|
||||||
|
const levels = await query(levelSql, levelParams);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...group,
|
||||||
|
levels: levels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "HG";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
hierarchyType = "MULTI_TABLE",
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels = "Y",
|
||||||
|
// Self-reference 설정
|
||||||
|
selfRefTable,
|
||||||
|
selfRefIdColumn,
|
||||||
|
selfRefParentColumn,
|
||||||
|
selfRefValueColumn,
|
||||||
|
selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn,
|
||||||
|
// BOM 설정
|
||||||
|
bomTable,
|
||||||
|
bomParentColumn,
|
||||||
|
bomChildColumn,
|
||||||
|
bomItemTable,
|
||||||
|
bomItemIdColumn,
|
||||||
|
bomItemLabelColumn,
|
||||||
|
bomQtyColumn,
|
||||||
|
bomLevelColumn,
|
||||||
|
// 메시지
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
// 레벨 (MULTI_TABLE 타입인 경우)
|
||||||
|
levels = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !hierarchyType) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateHierarchyGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, description, hierarchy_type,
|
||||||
|
max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
|
||||||
|
empty_message, no_options_message, loading_message,
|
||||||
|
company_code, is_active, created_by, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const group = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
hierarchyType,
|
||||||
|
maxLevels || null,
|
||||||
|
isFixedLevels,
|
||||||
|
selfRefTable || null,
|
||||||
|
selfRefIdColumn || null,
|
||||||
|
selfRefParentColumn || null,
|
||||||
|
selfRefValueColumn || null,
|
||||||
|
selfRefLabelColumn || null,
|
||||||
|
selfRefLevelColumn || null,
|
||||||
|
selfRefOrderColumn || null,
|
||||||
|
bomTable || null,
|
||||||
|
bomParentColumn || null,
|
||||||
|
bomChildColumn || null,
|
||||||
|
bomItemTable || null,
|
||||||
|
bomItemIdColumn || null,
|
||||||
|
bomItemLabelColumn || null,
|
||||||
|
bomQtyColumn || null,
|
||||||
|
bomLevelColumn || null,
|
||||||
|
emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage || "로딩 중...",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 레벨 생성 (MULTI_TABLE 타입인 경우)
|
||||||
|
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
|
||||||
|
for (const level of levels) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
level.levelOrder,
|
||||||
|
level.levelName,
|
||||||
|
level.levelCode || null,
|
||||||
|
level.tableName,
|
||||||
|
level.valueColumn,
|
||||||
|
level.labelColumn,
|
||||||
|
level.parentKeyColumn || null,
|
||||||
|
level.filterColumn || null,
|
||||||
|
level.filterValue || null,
|
||||||
|
level.orderColumn || null,
|
||||||
|
level.orderDirection || "ASC",
|
||||||
|
level.placeholder || `${level.levelName} 선택`,
|
||||||
|
level.isRequired || "Y",
|
||||||
|
level.isSearchable || "N",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 생성되었습니다.",
|
||||||
|
data: group,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
max_levels = COALESCE($3, max_levels),
|
||||||
|
is_fixed_levels = COALESCE($4, is_fixed_levels),
|
||||||
|
empty_message = COALESCE($5, empty_message),
|
||||||
|
no_options_message = COALESCE($6, no_options_message),
|
||||||
|
loading_message = COALESCE($7, loading_message),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_by = $9,
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $10 AND company_code = $11
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
userId,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 수정", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 먼저 삭제
|
||||||
|
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteLevelsSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(deleteLevelsSql, levelParams);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteGroupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroupSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteGroupSql, groupParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 삭제", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 추가
|
||||||
|
*/
|
||||||
|
export const addLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection = "ASC",
|
||||||
|
placeholder,
|
||||||
|
isRequired = "Y",
|
||||||
|
isSearchable = "N",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 그룹 존재 확인
|
||||||
|
const groupCheck = await queryOne(
|
||||||
|
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
|
||||||
|
[groupCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupCheck) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
groupCode,
|
||||||
|
groupCheck.company_code,
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode || null,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn || null,
|
||||||
|
filterColumn || null,
|
||||||
|
filterValue || null,
|
||||||
|
orderColumn || null,
|
||||||
|
orderDirection,
|
||||||
|
placeholder || `${levelName} 선택`,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 추가되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 추가 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 추가에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 수정
|
||||||
|
*/
|
||||||
|
export const updateLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_level SET
|
||||||
|
level_name = COALESCE($1, level_name),
|
||||||
|
table_name = COALESCE($2, table_name),
|
||||||
|
value_column = COALESCE($3, value_column),
|
||||||
|
label_column = COALESCE($4, label_column),
|
||||||
|
parent_key_column = COALESCE($5, parent_key_column),
|
||||||
|
filter_column = COALESCE($6, filter_column),
|
||||||
|
filter_value = COALESCE($7, filter_value),
|
||||||
|
order_column = COALESCE($8, order_column),
|
||||||
|
order_direction = COALESCE($9, order_direction),
|
||||||
|
placeholder = COALESCE($10, placeholder),
|
||||||
|
is_required = COALESCE($11, is_required),
|
||||||
|
is_searchable = COALESCE($12, is_searchable),
|
||||||
|
is_active = COALESCE($13, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE level_id = $14
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
Number(levelId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 수정", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 삭제
|
||||||
|
*/
|
||||||
|
export const deleteLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING level_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 레벨 삭제", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레벨의 옵션 조회
|
||||||
|
*/
|
||||||
|
export const getLevelOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode, levelOrder } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 정보 조회
|
||||||
|
let levelSql = `
|
||||||
|
SELECT l.*, g.hierarchy_type
|
||||||
|
FROM cascading_hierarchy_level l
|
||||||
|
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
|
||||||
|
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const levelParams: any[] = [groupCode, Number(levelOrder)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND l.company_code = $3`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = await queryOne(levelSql, levelParams);
|
||||||
|
|
||||||
|
if (!level) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${level.value_column} as value,
|
||||||
|
${level.label_column} as label
|
||||||
|
FROM ${level.table_name}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (레벨 2 이상)
|
||||||
|
if (level.parent_key_column && parentValue) {
|
||||||
|
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고정 필터
|
||||||
|
if (level.filter_column && level.filter_value) {
|
||||||
|
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(level.filter_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[level.table_name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (level.order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${level.label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 옵션 조회", {
|
||||||
|
groupCode,
|
||||||
|
levelOrder,
|
||||||
|
parentValue,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
levelInfo: {
|
||||||
|
levelId: level.level_id,
|
||||||
|
levelName: level.level_name,
|
||||||
|
placeholder: level.placeholder,
|
||||||
|
isRequired: level.is_required,
|
||||||
|
isSearchable: level.is_searchable,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
||||||
|
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getExclusions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_mutual_exclusion
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY exclusion_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getExclusionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const params: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배제 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "EX";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType = "SAME_VALUE",
|
||||||
|
errorMessage = "동일한 값을 선택할 수 없습니다",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배제 코드 자동 생성
|
||||||
|
const exclusionCode = await generateExclusionCode(companyCode);
|
||||||
|
|
||||||
|
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
|
||||||
|
const existingCheck = await queryOne(
|
||||||
|
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
|
||||||
|
[exclusionCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 배제 코드입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
exclusion_type, error_message,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
exclusionCode,
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn || null,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_mutual_exclusion SET
|
||||||
|
exclusion_name = COALESCE($1, exclusion_name),
|
||||||
|
field_names = COALESCE($2, field_names),
|
||||||
|
source_table = COALESCE($3, source_table),
|
||||||
|
value_column = COALESCE($4, value_column),
|
||||||
|
label_column = COALESCE($5, label_column),
|
||||||
|
exclusion_type = COALESCE($6, exclusion_type),
|
||||||
|
error_message = COALESCE($7, error_message),
|
||||||
|
is_active = COALESCE($8, is_active)
|
||||||
|
WHERE exclusion_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
Number(exclusionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING exclusion_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 검증
|
||||||
|
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||||
|
*/
|
||||||
|
export const validateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드명 파싱
|
||||||
|
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
|
||||||
|
|
||||||
|
// 필드 값 수집
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (fieldValues[field]) {
|
||||||
|
values.push(fieldValues[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
let isValid = true;
|
||||||
|
let errorMessage = null;
|
||||||
|
let conflictingFields: string[] = [];
|
||||||
|
|
||||||
|
if (exclusion.exclusion_type === "SAME_VALUE") {
|
||||||
|
// 같은 값이 있는지 확인
|
||||||
|
const uniqueValues = new Set(values);
|
||||||
|
if (uniqueValues.size !== values.length) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = exclusion.error_message;
|
||||||
|
|
||||||
|
// 충돌하는 필드 찾기
|
||||||
|
const valueCounts: Record<string, string[]> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const val = fieldValues[field];
|
||||||
|
if (val) {
|
||||||
|
if (!valueCounts[val]) {
|
||||||
|
valueCounts[val] = [];
|
||||||
|
}
|
||||||
|
valueCounts[val].push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, fieldList] of Object.entries(valueCounts)) {
|
||||||
|
if (fieldList.length > 1) {
|
||||||
|
conflictingFields = fieldList;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 검증", {
|
||||||
|
exclusionCode,
|
||||||
|
isValid,
|
||||||
|
fieldValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isValid,
|
||||||
|
errorMessage: isValid ? null : errorMessage,
|
||||||
|
conflictingFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 검증 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 검증에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드에 대한 배제 옵션 조회
|
||||||
|
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getExcludedOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
const labelColumn = exclusion.label_column || exclusion.value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${exclusion.value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${exclusion.source_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[exclusion.source_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 선택된 값 제외
|
||||||
|
if (selectedValues) {
|
||||||
|
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
|
||||||
|
if (excludeValues.length > 0) {
|
||||||
|
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||||
|
optionsParams.push(...excludeValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("상호 배제 옵션 조회", {
|
||||||
|
exclusionCode,
|
||||||
|
currentField,
|
||||||
|
excludedCount: (selectedValues as string)?.split(",").length || 0,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,750 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링
|
||||||
|
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
|
||||||
|
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $${paramIndex}`;
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $2`;
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
|
if (
|
||||||
|
tableInfoResult.rowCount &&
|
||||||
|
tableInfoResult.rowCount > 0 &&
|
||||||
|
companyCode !== "*"
|
||||||
|
) {
|
||||||
|
optionsQuery += ` AND company_code = $1`;
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
|
||||||
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
|
if (
|
||||||
|
tableInfoResult.rowCount &&
|
||||||
|
tableInfoResult.rowCount > 0 &&
|
||||||
|
companyCode !== "*"
|
||||||
|
) {
|
||||||
|
optionsQuery += ` AND company_code = $2`;
|
||||||
|
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,52 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAutoFillGroups,
|
||||||
|
getAutoFillGroupDetail,
|
||||||
|
createAutoFillGroup,
|
||||||
|
updateAutoFillGroup,
|
||||||
|
deleteAutoFillGroup,
|
||||||
|
getAutoFillMasterOptions,
|
||||||
|
getAutoFillData,
|
||||||
|
} from "../controllers/cascadingAutoFillController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/groups", getAutoFillGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (매핑 포함)
|
||||||
|
router.get("/groups/:groupCode", getAutoFillGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/groups", createAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/groups/:groupCode", updateAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/groups/:groupCode", deleteAutoFillGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 마스터 옵션 목록 조회
|
||||||
|
router.get("/options/:groupCode", getAutoFillMasterOptions);
|
||||||
|
|
||||||
|
// 자동 입력 데이터 조회
|
||||||
|
router.get("/data/:groupCode", getAutoFillData);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getConditions,
|
||||||
|
getConditionDetail,
|
||||||
|
createCondition,
|
||||||
|
updateCondition,
|
||||||
|
deleteCondition,
|
||||||
|
getFilteredOptions,
|
||||||
|
} from "../controllers/cascadingConditionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getConditions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:conditionId", getConditionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createCondition);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:conditionId", updateCondition);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:conditionId", deleteCondition);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 조건에 따른 필터링된 옵션 조회
|
||||||
|
router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getHierarchyGroups,
|
||||||
|
getHierarchyGroupDetail,
|
||||||
|
createHierarchyGroup,
|
||||||
|
updateHierarchyGroup,
|
||||||
|
deleteHierarchyGroup,
|
||||||
|
addLevel,
|
||||||
|
updateLevel,
|
||||||
|
deleteLevel,
|
||||||
|
getLevelOptions,
|
||||||
|
} from "../controllers/cascadingHierarchyController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/", getHierarchyGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (레벨 포함)
|
||||||
|
router.get("/:groupCode", getHierarchyGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/", createHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/:groupCode", updateHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/:groupCode", deleteHierarchyGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 레벨 추가
|
||||||
|
router.post("/:groupCode/levels", addLevel);
|
||||||
|
|
||||||
|
// 레벨 수정
|
||||||
|
router.put("/levels/:levelId", updateLevel);
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
router.delete("/levels/:levelId", deleteLevel);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 특정 레벨의 옵션 조회
|
||||||
|
router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getExclusions,
|
||||||
|
getExclusionDetail,
|
||||||
|
createExclusion,
|
||||||
|
updateExclusion,
|
||||||
|
deleteExclusion,
|
||||||
|
validateExclusion,
|
||||||
|
getExcludedOptions,
|
||||||
|
} from "../controllers/cascadingMutualExclusionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getExclusions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:exclusionId", getExclusionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createExclusion);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:exclusionId", updateExclusion);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:exclusionId", deleteExclusion);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 및 옵션 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
router.post("/validate/:exclusionCode", validateExclusion);
|
||||||
|
|
||||||
|
// 배제된 옵션 조회
|
||||||
|
router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,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;
|
||||||
|
|
||||||
|
|
@ -218,7 +218,10 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
*/
|
*/
|
||||||
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
router.post(
|
||||||
|
"/:flowId/execute",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { flowId } = req.params;
|
const { flowId } = req.params;
|
||||||
const contextData = req.body;
|
const contextData = req.body;
|
||||||
|
|
@ -229,6 +232,12 @@ router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequ
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔍 디버깅: req.user 전체 확인
|
||||||
|
logger.info(`🔍 req.user 전체 정보:`, {
|
||||||
|
user: req.user,
|
||||||
|
hasUser: !!req.user,
|
||||||
|
});
|
||||||
|
|
||||||
// 사용자 정보를 contextData에 추가
|
// 사용자 정보를 contextData에 추가
|
||||||
const enrichedContextData = {
|
const enrichedContextData = {
|
||||||
...contextData,
|
...contextData,
|
||||||
|
|
@ -237,6 +246,12 @@ router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequ
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔍 디버깅: enrichedContextData 확인
|
||||||
|
logger.info(`🔍 enrichedContextData:`, {
|
||||||
|
userId: enrichedContextData.userId,
|
||||||
|
companyCode: enrichedContextData.companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
// 플로우 실행
|
// 플로우 실행
|
||||||
const result = await NodeFlowExecutionService.executeFlow(
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
parseInt(flowId, 10),
|
parseInt(flowId, 10),
|
||||||
|
|
@ -258,6 +273,7 @@ router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequ
|
||||||
: "플로우 실행 중 오류가 발생했습니다.",
|
: "플로우 실행 중 오류가 발생했습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ class DataService {
|
||||||
|
|
||||||
case "base_price":
|
case "base_price":
|
||||||
// base_price = true인 행 찾기
|
// base_price = true인 행 찾기
|
||||||
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "current_date":
|
case "current_date":
|
||||||
|
|
@ -128,8 +128,11 @@ class DataService {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||||
|
|
||||||
selectedRow = rows.find(row => {
|
selectedRow =
|
||||||
const startDate = row.start_date ? new Date(row.start_date) : null;
|
rows.find((row) => {
|
||||||
|
const startDate = row.start_date
|
||||||
|
? new Date(row.start_date)
|
||||||
|
: null;
|
||||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||||
|
|
||||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -230,12 +233,17 @@ class DataService {
|
||||||
|
|
||||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
tableName,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
queryParams.push(userCompany);
|
queryParams.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +516,8 @@ class DataService {
|
||||||
const entityJoinService = new EntityJoinService();
|
const entityJoinService = new EntityJoinService();
|
||||||
|
|
||||||
// Entity Join 구성 감지
|
// Entity Join 구성 감지
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(tableName);
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||||
|
|
@ -533,14 +542,14 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -551,7 +560,10 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
console.log(
|
||||||
|
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
|
||||||
|
normalizedRows[0]
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||||
if (groupByColumns.length > 0) {
|
if (groupByColumns.length > 0) {
|
||||||
|
|
@ -574,7 +586,10 @@ class DataService {
|
||||||
if (groupConditions.length > 0) {
|
if (groupConditions.length > 0) {
|
||||||
const groupWhereClause = groupConditions.join(" AND ");
|
const groupWhereClause = groupConditions.join(" AND ");
|
||||||
|
|
||||||
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
console.log(
|
||||||
|
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
|
||||||
|
groupValues
|
||||||
|
);
|
||||||
|
|
||||||
// 그룹핑 기준으로 모든 레코드 조회
|
// 그룹핑 기준으로 모든 레코드 조회
|
||||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||||
|
|
@ -587,7 +602,9 @@ class DataService {
|
||||||
const groupResult = await pool.query(groupQuery, groupValues);
|
const groupResult = await pool.query(groupQuery, groupValues);
|
||||||
|
|
||||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
console.log(
|
||||||
|
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -642,7 +659,8 @@ class DataService {
|
||||||
dataFilter?: any, // 🆕 데이터 필터
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||||
deduplication?: { // 🆕 중복 제거 설정
|
deduplication?: {
|
||||||
|
// 🆕 중복 제거 설정
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupByColumn: string;
|
groupByColumn: string;
|
||||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
|
@ -666,7 +684,8 @@ class DataService {
|
||||||
if (enableEntityJoin) {
|
if (enableEntityJoin) {
|
||||||
try {
|
try {
|
||||||
const { entityJoinService } = await import("./entityJoinService");
|
const { entityJoinService } = await import("./entityJoinService");
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(rightTable);
|
||||||
|
|
||||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||||
if (displayColumns && Array.isArray(displayColumns)) {
|
if (displayColumns && Array.isArray(displayColumns)) {
|
||||||
|
|
@ -674,8 +693,8 @@ class DataService {
|
||||||
const tableColumns: Record<string, Set<string>> = {};
|
const tableColumns: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
for (const col of displayColumns) {
|
for (const col of displayColumns) {
|
||||||
if (col.name && col.name.includes('.')) {
|
if (col.name && col.name.includes(".")) {
|
||||||
const [refTable, refColumn] = col.name.split('.');
|
const [refTable, refColumn] = col.name.split(".");
|
||||||
if (!tableColumns[refTable]) {
|
if (!tableColumns[refTable]) {
|
||||||
tableColumns[refTable] = new Set();
|
tableColumns[refTable] = new Set();
|
||||||
}
|
}
|
||||||
|
|
@ -686,14 +705,18 @@ class DataService {
|
||||||
// 각 테이블별로 처리
|
// 각 테이블별로 처리
|
||||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||||
// 이미 조인 설정에 있는지 확인
|
// 이미 조인 설정에 있는지 확인
|
||||||
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
const existingJoins = joinConfigs.filter(
|
||||||
|
(jc) => jc.referenceTable === refTable
|
||||||
|
);
|
||||||
|
|
||||||
if (existingJoins.length > 0) {
|
if (existingJoins.length > 0) {
|
||||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||||
for (const refColumn of refColumns) {
|
for (const refColumn of refColumns) {
|
||||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||||
const existingJoin = existingJoins.find(
|
const existingJoin = existingJoins.find(
|
||||||
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
(jc) =>
|
||||||
|
jc.displayColumns.length === 1 &&
|
||||||
|
jc.displayColumns[0] === refColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!existingJoin) {
|
if (!existingJoin) {
|
||||||
|
|
@ -708,7 +731,9 @@ class DataService {
|
||||||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||||
};
|
};
|
||||||
joinConfigs.push(newJoin);
|
joinConfigs.push(newJoin);
|
||||||
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
console.log(
|
||||||
|
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -718,7 +743,9 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
console.log(
|
||||||
|
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
|
||||||
|
);
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
|
|
@ -735,7 +762,10 @@ class DataService {
|
||||||
|
|
||||||
// 회사별 필터링
|
// 회사별 필터링
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
|
|
@ -744,25 +774,41 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
dataFilter &&
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const { buildDataFilterWhereClause } = await import(
|
||||||
|
"../utils/dataFilterUtil"
|
||||||
|
);
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"main",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
const whereClause =
|
||||||
|
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||||
|
|
||||||
// Entity 조인 쿼리 빌드
|
// Entity 조인 쿼리 빌드
|
||||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||||
const selectColumns = ["*"];
|
const selectColumns = ["*"];
|
||||||
|
|
||||||
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
const { query: finalQuery, aliasMap } =
|
||||||
|
entityJoinService.buildJoinQuery(
|
||||||
rightTable,
|
rightTable,
|
||||||
joinConfigs,
|
joinConfigs,
|
||||||
selectColumns,
|
selectColumns,
|
||||||
|
|
@ -779,13 +825,13 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결
|
// 🔧 날짜 타입 타임존 문제 해결
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -796,14 +842,20 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
console.log(
|
||||||
|
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = normalizedRows;
|
let finalData = normalizedRows;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -838,23 +890,40 @@ class DataService {
|
||||||
|
|
||||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
dataFilter &&
|
||||||
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"r",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -871,9 +940,13 @@ class DataService {
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = result;
|
let finalData = result;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(result, deduplication);
|
finalData = this.deduplicateData(result, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -909,7 +982,9 @@ class DataService {
|
||||||
|
|
||||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
const validColumnNames = new Set(
|
||||||
|
tableColumns.map((col: any) => col.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
const invalidColumns: string[] = [];
|
const invalidColumns: string[] = [];
|
||||||
const filteredData = Object.fromEntries(
|
const filteredData = Object.fromEntries(
|
||||||
|
|
@ -923,7 +998,9 @@ class DataService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invalidColumns.length > 0) {
|
if (invalidColumns.length > 0) {
|
||||||
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
console.log(
|
||||||
|
`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = Object.keys(filteredData);
|
const columns = Object.keys(filteredData);
|
||||||
|
|
@ -975,7 +1052,9 @@ class DataService {
|
||||||
|
|
||||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
const tableColumns = await this.getTableColumnsSimple(tableName);
|
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||||
const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name));
|
const validColumnNames = new Set(
|
||||||
|
tableColumns.map((col: any) => col.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
const invalidColumns: string[] = [];
|
const invalidColumns: string[] = [];
|
||||||
cleanData = Object.fromEntries(
|
cleanData = Object.fromEntries(
|
||||||
|
|
@ -989,7 +1068,9 @@ class DataService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invalidColumns.length > 0) {
|
if (invalidColumns.length > 0) {
|
||||||
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
|
console.log(
|
||||||
|
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary Key 컬럼 찾기
|
// Primary Key 컬럼 찾기
|
||||||
|
|
@ -1031,8 +1112,14 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||||
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
if (
|
||||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
relationInfo &&
|
||||||
|
relationInfo.rightTable &&
|
||||||
|
relationInfo.leftColumn &&
|
||||||
|
relationInfo.rightColumn
|
||||||
|
) {
|
||||||
|
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
|
||||||
|
relationInfo;
|
||||||
const newLeftValue = cleanData[leftColumn];
|
const newLeftValue = cleanData[leftColumn];
|
||||||
|
|
||||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||||
|
|
@ -1050,8 +1137,13 @@ class DataService {
|
||||||
SET "${rightColumn}" = $1
|
SET "${rightColumn}" = $1
|
||||||
WHERE "${rightColumn}" = $2
|
WHERE "${rightColumn}" = $2
|
||||||
`;
|
`;
|
||||||
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
const updateResult = await query(updateRelatedQuery, [
|
||||||
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
newLeftValue,
|
||||||
|
oldLeftValue,
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
|
||||||
|
);
|
||||||
} catch (relError) {
|
} catch (relError) {
|
||||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||||
|
|
@ -1102,9 +1194,11 @@ class DataService {
|
||||||
|
|
||||||
if (pkResult.length > 1) {
|
if (pkResult.length > 1) {
|
||||||
// 복합키인 경우: id가 객체여야 함
|
// 복합키인 경우: id가 객체여야 함
|
||||||
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
|
console.log(
|
||||||
|
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
if (typeof id === 'object' && !Array.isArray(id)) {
|
if (typeof id === "object" && !Array.isArray(id)) {
|
||||||
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
||||||
pkResult.forEach((pk, index) => {
|
pkResult.forEach((pk, index) => {
|
||||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||||
|
|
@ -1119,15 +1213,17 @@ class DataService {
|
||||||
// 단일키인 경우
|
// 단일키인 경우
|
||||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||||
whereClauses.push(`"${pkColumn}" = $1`);
|
whereClauses.push(`"${pkColumn}" = $1`);
|
||||||
params.push(typeof id === 'object' ? id[pkColumn] : id);
|
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
console.log(
|
||||||
|
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -1166,7 +1262,11 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whereConditions.length === 0) {
|
if (whereConditions.length === 0) {
|
||||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "삭제 조건이 없습니다.",
|
||||||
|
error: "NO_CONDITIONS",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
@ -1201,7 +1301,9 @@ class DataService {
|
||||||
records: Array<Record<string, any>>,
|
records: Array<Record<string, any>>,
|
||||||
userCompany?: string,
|
userCompany?: string,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
): Promise<
|
||||||
|
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 권한 검증
|
// 테이블 접근 권한 검증
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1240,7 +1342,10 @@ class DataService {
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||||
|
|
||||||
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
console.log(`📋 기존 레코드 조회:`, {
|
||||||
|
query: selectQuery,
|
||||||
|
values: whereValues,
|
||||||
|
});
|
||||||
|
|
||||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||||
|
|
||||||
|
|
@ -1256,8 +1361,8 @@ class DataService {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
|
|
||||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -1294,8 +1399,11 @@ class DataService {
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
// Date 타입 처리
|
// Date 타입 처리
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 비교
|
// 문자열 비교
|
||||||
|
|
@ -1310,7 +1418,8 @@ class DataService {
|
||||||
let updateParamIndex = 1;
|
let updateParamIndex = 1;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fullRecord)) {
|
for (const [key, value] of Object.entries(fullRecord)) {
|
||||||
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
if (key !== pkColumn) {
|
||||||
|
// Primary Key는 업데이트하지 않음
|
||||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||||
updateValues.push(value);
|
updateValues.push(value);
|
||||||
updateParamIndex++;
|
updateParamIndex++;
|
||||||
|
|
@ -1332,15 +1441,21 @@ class DataService {
|
||||||
// INSERT: 기존 레코드가 없으면 삽입
|
// INSERT: 기존 레코드가 없으면 삽입
|
||||||
|
|
||||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||||
|
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||||
|
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||||
const recordWithMeta: Record<string, any> = {
|
const recordWithMeta: Record<string, any> = {
|
||||||
...fullRecord,
|
...recordWithoutCreatedDate,
|
||||||
id: uuidv4(), // 새 ID 생성
|
id: uuidv4(), // 새 ID 생성
|
||||||
created_date: "NOW()",
|
created_date: "NOW()",
|
||||||
updated_date: "NOW()",
|
updated_date: "NOW()",
|
||||||
};
|
};
|
||||||
|
|
||||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
if (
|
||||||
|
!recordWithMeta.company_code &&
|
||||||
|
userCompany &&
|
||||||
|
userCompany !== "*"
|
||||||
|
) {
|
||||||
recordWithMeta.company_code = userCompany;
|
recordWithMeta.company_code = userCompany;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1349,8 +1464,8 @@ class DataService {
|
||||||
recordWithMeta.writer = userId;
|
recordWithMeta.writer = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
const insertFields = Object.keys(recordWithMeta).filter(
|
||||||
recordWithMeta[key] !== "NOW()"
|
(key) => recordWithMeta[key] !== "NOW()"
|
||||||
);
|
);
|
||||||
const insertPlaceholders: string[] = [];
|
const insertPlaceholders: string[] = [];
|
||||||
const insertValues: any[] = [];
|
const insertValues: any[] = [];
|
||||||
|
|
@ -1367,11 +1482,16 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
|
||||||
|
.map((f) => `"${f}"`)
|
||||||
|
.join(", ")})
|
||||||
VALUES (${insertPlaceholders.join(", ")})
|
VALUES (${insertPlaceholders.join(", ")})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
console.log(`➕ INSERT 쿼리:`, {
|
||||||
|
query: insertQuery,
|
||||||
|
values: insertValues,
|
||||||
|
});
|
||||||
|
|
||||||
await pool.query(insertQuery, insertValues);
|
await pool.query(insertQuery, insertValues);
|
||||||
inserted++;
|
inserted++;
|
||||||
|
|
@ -1392,8 +1512,11 @@ class DataService {
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,16 @@ export class DynamicFormService {
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
// DATE 타입이면 문자열 그대로 유지
|
// DATE 타입이면 문자열 그대로 유지
|
||||||
if (lowerDataType === "date") {
|
if (lowerDataType === "date") {
|
||||||
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
|
console.log(
|
||||||
|
`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`
|
||||||
|
);
|
||||||
return value; // 문자열 그대로 반환
|
return value; // 문자열 그대로 반환
|
||||||
}
|
}
|
||||||
// TIMESTAMP 타입이면 Date 객체로 변환
|
// TIMESTAMP 타입이면 Date 객체로 변환
|
||||||
else {
|
else {
|
||||||
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
|
console.log(
|
||||||
|
`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`
|
||||||
|
);
|
||||||
return new Date(value + "T00:00:00");
|
return new Date(value + "T00:00:00");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +254,8 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
||||||
dataToInsert.regdate = new Date();
|
dataToInsert.regdate = new Date();
|
||||||
}
|
}
|
||||||
if (tableColumns.includes("created_date") && !dataToInsert.created_date) {
|
// created_date는 항상 현재 시간으로 설정 (기존 값 무시)
|
||||||
|
if (tableColumns.includes("created_date")) {
|
||||||
dataToInsert.created_date = new Date();
|
dataToInsert.created_date = new Date();
|
||||||
}
|
}
|
||||||
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
||||||
|
|
@ -313,7 +318,9 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
||||||
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
|
console.log(
|
||||||
|
`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`
|
||||||
|
);
|
||||||
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +362,11 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파싱된 배열이 있으면 처리
|
// 파싱된 배열이 있으면 처리
|
||||||
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
if (
|
||||||
|
parsedArray &&
|
||||||
|
Array.isArray(parsedArray) &&
|
||||||
|
parsedArray.length > 0
|
||||||
|
) {
|
||||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||||
let targetTable: string | undefined;
|
let targetTable: string | undefined;
|
||||||
|
|
@ -364,9 +375,7 @@ export class DynamicFormService {
|
||||||
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
||||||
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
||||||
targetTable = parsedArray[0]._targetTable;
|
targetTable = parsedArray[0]._targetTable;
|
||||||
actualData = parsedArray.map(
|
actualData = parsedArray.map(({ _targetTable, ...item }) => item);
|
||||||
({ _targetTable, ...item }) => item
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repeaterData.push({
|
repeaterData.push({
|
||||||
|
|
@ -388,7 +397,7 @@ export class DynamicFormService {
|
||||||
const separateRepeaterData: typeof repeaterData = [];
|
const separateRepeaterData: typeof repeaterData = [];
|
||||||
const mergedRepeaterData: typeof repeaterData = [];
|
const mergedRepeaterData: typeof repeaterData = [];
|
||||||
|
|
||||||
repeaterData.forEach(repeater => {
|
repeaterData.forEach((repeater) => {
|
||||||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||||
// 다른 테이블: 나중에 별도 저장
|
// 다른 테이블: 나중에 별도 저장
|
||||||
separateRepeaterData.push(repeater);
|
separateRepeaterData.push(repeater);
|
||||||
|
|
@ -497,14 +506,21 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||||
if (mergedRepeaterData.length > 0) {
|
if (mergedRepeaterData.length > 0) {
|
||||||
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
console.log(
|
||||||
|
`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`
|
||||||
|
);
|
||||||
|
|
||||||
result = [];
|
result = [];
|
||||||
|
|
||||||
for (const repeater of mergedRepeaterData) {
|
for (const repeater of mergedRepeaterData) {
|
||||||
for (const item of repeater.data) {
|
for (const item of repeater.data) {
|
||||||
// 헤더 + 품목을 병합
|
// 헤더 + 품목을 병합
|
||||||
const rawMergedData = { ...dataToInsert, ...item };
|
// item에서 created_date 제거 (dataToInsert의 현재 시간 유지)
|
||||||
|
const { created_date: _, ...itemWithoutCreatedDate } = item;
|
||||||
|
const rawMergedData = {
|
||||||
|
...dataToInsert,
|
||||||
|
...itemWithoutCreatedDate,
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
||||||
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
||||||
|
|
@ -531,7 +547,9 @@ export class DynamicFormService {
|
||||||
Object.keys(rawMergedData).forEach((columnName) => {
|
Object.keys(rawMergedData).forEach((columnName) => {
|
||||||
// 실제 테이블 컬럼인지 확인
|
// 실제 테이블 컬럼인지 확인
|
||||||
if (validColumnNames.includes(columnName)) {
|
if (validColumnNames.includes(columnName)) {
|
||||||
const column = columnInfo.find((col) => col.column_name === columnName);
|
const column = columnInfo.find(
|
||||||
|
(col) => col.column_name === columnName
|
||||||
|
);
|
||||||
if (column) {
|
if (column) {
|
||||||
// 타입 변환
|
// 타입 변환
|
||||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||||
|
|
@ -542,13 +560,17 @@ export class DynamicFormService {
|
||||||
mergedData[columnName] = rawMergedData[columnName];
|
mergedData[columnName] = rawMergedData[columnName];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
|
console.log(
|
||||||
|
`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedColumns = Object.keys(mergedData);
|
const mergedColumns = Object.keys(mergedData);
|
||||||
const mergedValues: any[] = Object.values(mergedData);
|
const mergedValues: any[] = Object.values(mergedData);
|
||||||
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
const mergedPlaceholders = mergedValues
|
||||||
|
.map((_, index) => `$${index + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
let mergedUpsertQuery: string;
|
let mergedUpsertQuery: string;
|
||||||
if (primaryKeys.length > 0) {
|
if (primaryKeys.length > 0) {
|
||||||
|
|
@ -732,12 +754,19 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (새로 추가)
|
// 🎯 제어관리 실행 (새로 추가)
|
||||||
try {
|
try {
|
||||||
|
// savedData 또는 insertedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
(insertedRecord as Record<string, any>)?.company_code ||
|
||||||
|
dataToInsert.company_code ||
|
||||||
|
"*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
insertedRecord as Record<string, any>,
|
insertedRecord as Record<string, any>,
|
||||||
"insert",
|
"insert",
|
||||||
created_by || "system"
|
created_by || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -843,10 +872,10 @@ export class DynamicFormService {
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1 AND table_schema = 'public'
|
WHERE table_name = $1 AND table_schema = 'public'
|
||||||
`;
|
`;
|
||||||
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
|
const columnTypesResult = await query<{
|
||||||
columnTypesQuery,
|
column_name: string;
|
||||||
[tableName]
|
data_type: string;
|
||||||
);
|
}>(columnTypesQuery, [tableName]);
|
||||||
const columnTypes: Record<string, string> = {};
|
const columnTypes: Record<string, string> = {};
|
||||||
columnTypesResult.forEach((row) => {
|
columnTypesResult.forEach((row) => {
|
||||||
columnTypes[row.column_name] = row.data_type;
|
columnTypes[row.column_name] = row.data_type;
|
||||||
|
|
@ -859,11 +888,20 @@ export class DynamicFormService {
|
||||||
.map((key, index) => {
|
.map((key, index) => {
|
||||||
const dataType = columnTypes[key];
|
const dataType = columnTypes[key];
|
||||||
// 숫자 타입인 경우 명시적 캐스팅
|
// 숫자 타입인 경우 명시적 캐스팅
|
||||||
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
|
if (
|
||||||
|
dataType === "integer" ||
|
||||||
|
dataType === "bigint" ||
|
||||||
|
dataType === "smallint"
|
||||||
|
) {
|
||||||
return `${key} = $${index + 1}::integer`;
|
return `${key} = $${index + 1}::integer`;
|
||||||
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
|
} else if (
|
||||||
|
dataType === "numeric" ||
|
||||||
|
dataType === "decimal" ||
|
||||||
|
dataType === "real" ||
|
||||||
|
dataType === "double precision"
|
||||||
|
) {
|
||||||
return `${key} = $${index + 1}::numeric`;
|
return `${key} = $${index + 1}::numeric`;
|
||||||
} else if (dataType === 'boolean') {
|
} else if (dataType === "boolean") {
|
||||||
return `${key} = $${index + 1}::boolean`;
|
return `${key} = $${index + 1}::boolean`;
|
||||||
} else {
|
} else {
|
||||||
// 문자열 타입은 캐스팅 불필요
|
// 문자열 타입은 캐스팅 불필요
|
||||||
|
|
@ -877,13 +915,17 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||||||
const pkDataType = columnTypes[primaryKeyColumn];
|
const pkDataType = columnTypes[primaryKeyColumn];
|
||||||
let pkCast = '';
|
let pkCast = "";
|
||||||
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
|
if (
|
||||||
pkCast = '::integer';
|
pkDataType === "integer" ||
|
||||||
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
|
pkDataType === "bigint" ||
|
||||||
pkCast = '::numeric';
|
pkDataType === "smallint"
|
||||||
} else if (pkDataType === 'uuid') {
|
) {
|
||||||
pkCast = '::uuid';
|
pkCast = "::integer";
|
||||||
|
} else if (pkDataType === "numeric" || pkDataType === "decimal") {
|
||||||
|
pkCast = "::numeric";
|
||||||
|
} else if (pkDataType === "uuid") {
|
||||||
|
pkCast = "::uuid";
|
||||||
}
|
}
|
||||||
// text, varchar 등은 캐스팅 불필요
|
// text, varchar 등은 캐스팅 불필요
|
||||||
|
|
||||||
|
|
@ -1072,12 +1114,19 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (UPDATE 트리거)
|
// 🎯 제어관리 실행 (UPDATE 트리거)
|
||||||
try {
|
try {
|
||||||
|
// updatedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
(updatedRecord as Record<string, any>)?.company_code ||
|
||||||
|
company_code ||
|
||||||
|
"*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
updatedRecord as Record<string, any>,
|
updatedRecord as Record<string, any>,
|
||||||
"update",
|
"update",
|
||||||
updated_by || "system"
|
updated_by || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1216,12 +1265,17 @@ export class DynamicFormService {
|
||||||
try {
|
try {
|
||||||
if (result && Array.isArray(result) && result.length > 0) {
|
if (result && Array.isArray(result) && result.length > 0) {
|
||||||
const deletedRecord = result[0] as Record<string, any>;
|
const deletedRecord = result[0] as Record<string, any>;
|
||||||
|
// deletedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
deletedRecord,
|
deletedRecord,
|
||||||
"delete",
|
"delete",
|
||||||
userId || "system"
|
userId || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
|
|
@ -1527,7 +1581,8 @@ export class DynamicFormService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
savedData: Record<string, any>,
|
savedData: Record<string, any>,
|
||||||
triggerType: "insert" | "update" | "delete",
|
triggerType: "insert" | "update" | "delete",
|
||||||
userId: string = "system"
|
userId: string = "system",
|
||||||
|
companyCode: string = "*"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||||
|
|
@ -1556,9 +1611,11 @@ export class DynamicFormService {
|
||||||
componentId: layout.component_id,
|
componentId: layout.component_id,
|
||||||
componentType: properties?.componentType,
|
componentType: properties?.componentType,
|
||||||
actionType: properties?.componentConfig?.action?.type,
|
actionType: properties?.componentConfig?.action?.type,
|
||||||
enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl,
|
enableDataflowControl:
|
||||||
|
properties?.webTypeConfig?.enableDataflowControl,
|
||||||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||||
hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
hasDiagramId:
|
||||||
|
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||||
|
|
@ -1587,16 +1644,22 @@ export class DynamicFormService {
|
||||||
if (!relationshipId) {
|
if (!relationshipId) {
|
||||||
// 노드 플로우 실행
|
// 노드 플로우 실행
|
||||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
const { NodeFlowExecutionService } = await import(
|
||||||
|
"./nodeFlowExecutionService"
|
||||||
|
);
|
||||||
|
|
||||||
const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, {
|
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||||
|
diagramId,
|
||||||
|
{
|
||||||
sourceData: [savedData],
|
sourceData: [savedData],
|
||||||
dataSourceType: "formData",
|
dataSourceType: "formData",
|
||||||
buttonId: "save-button",
|
buttonId: "save-button",
|
||||||
screenId: screenId,
|
screenId: screenId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
formData: savedData,
|
formData: savedData,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
controlResult = {
|
controlResult = {
|
||||||
success: executionResult.success,
|
success: executionResult.success,
|
||||||
|
|
@ -1612,8 +1675,11 @@ export class DynamicFormService {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 관계 기반 제어관리 실행
|
// 관계 기반 제어관리 실행
|
||||||
console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`);
|
console.log(
|
||||||
controlResult = await this.dataflowControlService.executeDataflowControl(
|
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||||||
|
);
|
||||||
|
controlResult =
|
||||||
|
await this.dataflowControlService.executeDataflowControl(
|
||||||
diagramId,
|
diagramId,
|
||||||
relationshipId,
|
relationshipId,
|
||||||
triggerType,
|
triggerType,
|
||||||
|
|
@ -1695,11 +1761,13 @@ export class DynamicFormService {
|
||||||
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
||||||
`;
|
`;
|
||||||
const columnResult = await client.query(columnQuery, [tableName]);
|
const columnResult = await client.query(columnQuery, [tableName]);
|
||||||
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
|
const existingColumns = columnResult.rows.map(
|
||||||
|
(row: any) => row.column_name
|
||||||
|
);
|
||||||
|
|
||||||
const hasUpdatedBy = existingColumns.includes('updated_by');
|
const hasUpdatedBy = existingColumns.includes("updated_by");
|
||||||
const hasUpdatedAt = existingColumns.includes('updated_at');
|
const hasUpdatedAt = existingColumns.includes("updated_at");
|
||||||
const hasCompanyCode = existingColumns.includes('company_code');
|
const hasCompanyCode = existingColumns.includes("company_code");
|
||||||
|
|
||||||
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
||||||
hasUpdatedBy,
|
hasUpdatedBy,
|
||||||
|
|
@ -1896,7 +1964,8 @@ export class DynamicFormService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
||||||
|
|
||||||
const sqlQuery = `
|
const sqlQuery = `
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,238 @@
|
||||||
|
# 마이그레이션 063-064: 재고 관리 테이블 생성
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
재고 현황 관리 및 입출고 이력 추적을 위한 테이블 생성
|
||||||
|
|
||||||
|
**테이블 타입관리 UI와 동일한 방식으로 생성됩니다.**
|
||||||
|
|
||||||
|
### 생성되는 테이블
|
||||||
|
|
||||||
|
| 테이블명 | 설명 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `inventory_stock` | 재고 현황 | 품목+로트별 현재 재고 상태 |
|
||||||
|
| `inventory_history` | 재고 이력 | 입출고 트랜잭션 기록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 타입관리 UI 방식 특징
|
||||||
|
|
||||||
|
1. **기본 컬럼 자동 포함**: `id`, `created_date`, `updated_date`, `writer`, `company_code`
|
||||||
|
2. **데이터 타입 통일**: 날짜는 `TIMESTAMP`, 나머지는 `VARCHAR(500)`
|
||||||
|
3. **메타데이터 등록**:
|
||||||
|
- `table_labels`: 테이블 정보
|
||||||
|
- `column_labels`: 컬럼 정보 (라벨, input_type, detail_settings)
|
||||||
|
- `table_type_columns`: 회사별 컬럼 타입 정보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 구조
|
||||||
|
|
||||||
|
### 1. inventory_stock (재고 현황)
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | input_type | 설명 |
|
||||||
|
|--------|------|------------|------|
|
||||||
|
| id | VARCHAR(500) | text | PK (자동생성) |
|
||||||
|
| created_date | TIMESTAMP | date | 생성일시 |
|
||||||
|
| updated_date | TIMESTAMP | date | 수정일시 |
|
||||||
|
| writer | VARCHAR(500) | text | 작성자 |
|
||||||
|
| company_code | VARCHAR(500) | text | 회사코드 |
|
||||||
|
| item_code | VARCHAR(500) | text | 품목코드 |
|
||||||
|
| lot_number | VARCHAR(500) | text | 로트번호 |
|
||||||
|
| warehouse_id | VARCHAR(500) | entity | 창고 (FK → warehouse_info) |
|
||||||
|
| location_code | VARCHAR(500) | text | 위치코드 |
|
||||||
|
| current_qty | VARCHAR(500) | number | 현재고량 |
|
||||||
|
| safety_qty | VARCHAR(500) | number | 안전재고 |
|
||||||
|
| last_in_date | TIMESTAMP | date | 최종입고일 |
|
||||||
|
| last_out_date | TIMESTAMP | date | 최종출고일 |
|
||||||
|
|
||||||
|
### 2. inventory_history (재고 이력)
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | input_type | 설명 |
|
||||||
|
|--------|------|------------|------|
|
||||||
|
| id | VARCHAR(500) | text | PK (자동생성) |
|
||||||
|
| created_date | TIMESTAMP | date | 생성일시 |
|
||||||
|
| updated_date | TIMESTAMP | date | 수정일시 |
|
||||||
|
| writer | VARCHAR(500) | text | 작성자 |
|
||||||
|
| company_code | VARCHAR(500) | text | 회사코드 |
|
||||||
|
| stock_id | VARCHAR(500) | text | 재고ID (FK) |
|
||||||
|
| item_code | VARCHAR(500) | text | 품목코드 |
|
||||||
|
| lot_number | VARCHAR(500) | text | 로트번호 |
|
||||||
|
| transaction_type | VARCHAR(500) | code | 구분 (IN/OUT) |
|
||||||
|
| transaction_date | TIMESTAMP | date | 일자 |
|
||||||
|
| quantity | VARCHAR(500) | number | 수량 |
|
||||||
|
| balance_qty | VARCHAR(500) | number | 재고량 |
|
||||||
|
| manager_id | VARCHAR(500) | text | 담당자ID |
|
||||||
|
| manager_name | VARCHAR(500) | text | 담당자명 |
|
||||||
|
| remark | VARCHAR(500) | text | 비고 |
|
||||||
|
| reference_type | VARCHAR(500) | text | 참조문서유형 |
|
||||||
|
| reference_id | VARCHAR(500) | text | 참조문서ID |
|
||||||
|
| reference_number | VARCHAR(500) | text | 참조문서번호 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
### Docker 환경 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 재고 현황 테이블
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/063_create_inventory_stock.sql
|
||||||
|
|
||||||
|
# 재고 이력 테이블
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/064_create_inventory_history.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로컬 PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/063_create_inventory_stock.sql
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/064_create_inventory_history.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### pgAdmin / DBeaver
|
||||||
|
|
||||||
|
1. 각 SQL 파일 열기
|
||||||
|
2. 전체 내용 복사
|
||||||
|
3. SQL 쿼리 창에 붙여넣기
|
||||||
|
4. 실행 (F5 또는 Execute)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 방법
|
||||||
|
|
||||||
|
### 1. 테이블 생성 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 메타데이터 등록 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_labels
|
||||||
|
SELECT * FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
|
||||||
|
-- column_labels
|
||||||
|
SELECT table_name, column_name, column_label, input_type, display_order
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history')
|
||||||
|
ORDER BY table_name, display_order;
|
||||||
|
|
||||||
|
-- table_type_columns
|
||||||
|
SELECT table_name, column_name, company_code, input_type, display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history')
|
||||||
|
ORDER BY table_name, display_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 샘플 데이터 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 재고 현황
|
||||||
|
SELECT * FROM inventory_stock WHERE company_code = 'WACE';
|
||||||
|
|
||||||
|
-- 재고 이력
|
||||||
|
SELECT * FROM inventory_history WHERE company_code = 'WACE' ORDER BY transaction_date;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 화면에서 사용할 조회 쿼리 예시
|
||||||
|
|
||||||
|
### 재고 현황 그리드 (좌측)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
s.item_code,
|
||||||
|
i.item_name,
|
||||||
|
i.size as specification,
|
||||||
|
i.unit,
|
||||||
|
s.lot_number,
|
||||||
|
w.warehouse_name,
|
||||||
|
s.location_code,
|
||||||
|
s.current_qty::numeric as current_qty,
|
||||||
|
s.safety_qty::numeric as safety_qty,
|
||||||
|
CASE
|
||||||
|
WHEN s.current_qty::numeric < s.safety_qty::numeric THEN '부족'
|
||||||
|
WHEN s.current_qty::numeric > s.safety_qty::numeric * 2 THEN '과다'
|
||||||
|
ELSE '정상'
|
||||||
|
END AS stock_status,
|
||||||
|
s.last_in_date,
|
||||||
|
s.last_out_date
|
||||||
|
FROM inventory_stock s
|
||||||
|
LEFT JOIN item_info i ON s.item_code = i.item_number AND s.company_code = i.company_code
|
||||||
|
LEFT JOIN warehouse_info w ON s.warehouse_id = w.id
|
||||||
|
WHERE s.company_code = 'WACE'
|
||||||
|
ORDER BY s.item_code, s.lot_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 재고 이력 패널 (우측)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
h.transaction_type,
|
||||||
|
h.transaction_date,
|
||||||
|
h.quantity,
|
||||||
|
h.balance_qty,
|
||||||
|
h.manager_name,
|
||||||
|
h.remark
|
||||||
|
FROM inventory_history h
|
||||||
|
WHERE h.item_code = 'A001'
|
||||||
|
AND h.lot_number = 'LOT-2024-001'
|
||||||
|
AND h.company_code = 'WACE'
|
||||||
|
ORDER BY h.transaction_date DESC, h.created_date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[입고 발생]
|
||||||
|
│
|
||||||
|
├─→ inventory_history에 INSERT (+수량, 잔량)
|
||||||
|
│
|
||||||
|
└─→ inventory_stock에 UPDATE (current_qty 증가, last_in_date 갱신)
|
||||||
|
|
||||||
|
[출고 발생]
|
||||||
|
│
|
||||||
|
├─→ inventory_history에 INSERT (-수량, 잔량)
|
||||||
|
│
|
||||||
|
└─→ inventory_stock에 UPDATE (current_qty 감소, last_out_date 갱신)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤백 방법 (문제 발생 시)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 테이블 삭제
|
||||||
|
DROP TABLE IF EXISTS inventory_history;
|
||||||
|
DROP TABLE IF EXISTS inventory_stock;
|
||||||
|
|
||||||
|
-- 메타데이터 삭제
|
||||||
|
DELETE FROM column_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
DELETE FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
DELETE FROM table_type_columns WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 테이블 (마스터 데이터)
|
||||||
|
|
||||||
|
| 테이블 | 역할 | 연결 컬럼 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| item_info | 품목 마스터 | item_number |
|
||||||
|
| warehouse_info | 창고 마스터 | id |
|
||||||
|
| warehouse_location | 위치 마스터 | location_code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-12-09
|
||||||
|
**영향 범위**: 재고 관리 시스템
|
||||||
|
**생성 방식**: 테이블 타입관리 UI와 동일
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -581,3 +581,4 @@ const result = await executeNodeFlow(flowId, {
|
||||||
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,699 @@
|
||||||
|
# 레벨 기반 연쇄 드롭다운 시스템 설계
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축
|
||||||
|
|
||||||
|
### 1.2 지원하는 계층 유형
|
||||||
|
|
||||||
|
| 유형 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 |
|
||||||
|
| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 |
|
||||||
|
| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 |
|
||||||
|
| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 데이터베이스 설계
|
||||||
|
|
||||||
|
### 2.1 테이블 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_group │ ← 계층 그룹 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (PK) │
|
||||||
|
│ group_name │
|
||||||
|
│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE
|
||||||
|
│ max_levels │
|
||||||
|
│ is_fixed_levels │
|
||||||
|
│ self_ref_* (자기참조 설정) │
|
||||||
|
│ bom_* (BOM 설정) │
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (MULTI_TABLE 유형만)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (FK) │
|
||||||
|
│ level_order │ ← 1, 2, 3...
|
||||||
|
│ level_name │
|
||||||
|
│ table_name │
|
||||||
|
│ value_column │
|
||||||
|
│ label_column │
|
||||||
|
│ parent_key_column │ ← 부모 테이블 참조 컬럼
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 기존 시스템과의 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_relation │ ← 기존 2단계 관계 (유지)
|
||||||
|
│ (2단계 전용) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 호환성 뷰
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 계층 유형별 상세 설계
|
||||||
|
|
||||||
|
### 3.1 MULTI_TABLE (다중 테이블 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 국가 → 시/도 → 구/군 → 동
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
country_info province_info city_info district_info
|
||||||
|
├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK)
|
||||||
|
├─ country_name ├─ province_name ├─ city_name ├─ district_name
|
||||||
|
├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK)
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
) VALUES (
|
||||||
|
'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 레벨 정의
|
||||||
|
INSERT INTO cascading_hierarchy_level VALUES
|
||||||
|
(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL),
|
||||||
|
(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'),
|
||||||
|
(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'),
|
||||||
|
(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1
|
||||||
|
→ [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR
|
||||||
|
→ [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL
|
||||||
|
→ [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SELF_REFERENCE (자기참조 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류)
|
||||||
|
|
||||||
|
**테이블 구조** (code_info 활용):
|
||||||
|
```
|
||||||
|
code_info
|
||||||
|
├─ code_category = 'PRODUCT_CATEGORY'
|
||||||
|
├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED'
|
||||||
|
├─ code_name = '전자제품', 'TV', 'LED TV'
|
||||||
|
├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조
|
||||||
|
├─ level = 1, 2, 3
|
||||||
|
├─ sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column,
|
||||||
|
self_ref_filter_column, self_ref_filter_value,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3,
|
||||||
|
'code_info', 'code_value', 'parent_code',
|
||||||
|
'code_value', 'code_name', 'level',
|
||||||
|
'code_category', 'PRODUCT_CATEGORY',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1
|
||||||
|
→ WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC
|
||||||
|
→ WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV
|
||||||
|
→ WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 BOM (Bill of Materials)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 BOM 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
klbom_tbl (BOM 관계) item_info (품목 마스터)
|
||||||
|
├─ id (자식 품목) ├─ item_code (PK)
|
||||||
|
├─ pid (부모 품목) ├─ item_name
|
||||||
|
├─ qty (수량) ├─ item_spec
|
||||||
|
├─ aylevel (레벨) ├─ unit
|
||||||
|
├─ bom_report_objid
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column,
|
||||||
|
bom_qty_column, bom_level_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N',
|
||||||
|
'klbom_tbl', 'pid', 'id',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'qty', 'aylevel',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots
|
||||||
|
→ WHERE pid IS NULL OR pid = ''
|
||||||
|
→ [{ value: 'PROD001', label: '완제품 A', level: 1 }]
|
||||||
|
|
||||||
|
2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001
|
||||||
|
→ WHERE pid = 'PROD001'
|
||||||
|
→ [
|
||||||
|
{ value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 },
|
||||||
|
{ value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001
|
||||||
|
→ WHERE pid = 'ASSY001'
|
||||||
|
→ [
|
||||||
|
{ value: 'PART001', label: '부품 A', qty: 4, level: 3 },
|
||||||
|
{ value: 'PART002', label: '부품 B', qty: 2, level: 3 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**BOM 전용 응답 형식**:
|
||||||
|
```typescript
|
||||||
|
interface BomOption {
|
||||||
|
value: string; // 품목 코드
|
||||||
|
label: string; // 품목명
|
||||||
|
qty: number; // 수량
|
||||||
|
level: number; // BOM 레벨
|
||||||
|
hasChildren: boolean; // 하위 품목 존재 여부
|
||||||
|
spec?: string; // 규격 (선택)
|
||||||
|
unit?: string; // 단위 (선택)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 TREE (무한 깊이 트리)
|
||||||
|
|
||||||
|
**사용 사례**: 조직도, 메뉴 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
dept_info
|
||||||
|
├─ dept_code (PK)
|
||||||
|
├─ dept_name
|
||||||
|
├─ parent_dept_code ← 자기참조 (무한 깊이)
|
||||||
|
├─ sort_order
|
||||||
|
├─ is_active
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_order_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ORG_CHART', '조직도', 'TREE', NULL, 'N',
|
||||||
|
'dept_info', 'dept_code', 'parent_dept_code',
|
||||||
|
'dept_code', 'dept_name', 'sort_order',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름** (BOM과 유사):
|
||||||
|
```
|
||||||
|
1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots
|
||||||
|
→ WHERE parent_dept_code IS NULL
|
||||||
|
→ [{ value: 'HQ', label: '본사', hasChildren: true }]
|
||||||
|
|
||||||
|
2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ
|
||||||
|
→ WHERE parent_dept_code = 'HQ'
|
||||||
|
→ [
|
||||||
|
{ value: 'DIV1', label: '사업부1', hasChildren: true },
|
||||||
|
{ value: 'DIV2', label: '사업부2', hasChildren: true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계
|
||||||
|
|
||||||
|
### 4.1 계층 그룹 관리 API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups # 그룹 목록
|
||||||
|
POST /api/cascading-hierarchy/groups # 그룹 생성
|
||||||
|
GET /api/cascading-hierarchy/groups/:code # 그룹 상세
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code # 그룹 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 레벨 관리 API (MULTI_TABLE용)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록
|
||||||
|
POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 옵션 조회 API
|
||||||
|
|
||||||
|
```
|
||||||
|
# MULTI_TABLE / SELF_REFERENCE
|
||||||
|
GET /api/cascading-hierarchy/options/:groupCode/:level
|
||||||
|
?parentValue=xxx # 부모 값 (레벨 2 이상)
|
||||||
|
&companyCode=xxx # 회사 코드 (선택)
|
||||||
|
|
||||||
|
# BOM / TREE
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드
|
||||||
|
?parentValue=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회
|
||||||
|
?value=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/search # 검색
|
||||||
|
?keyword=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 컴포넌트 설계
|
||||||
|
|
||||||
|
### 5.1 CascadingHierarchyDropdown
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyDropdownProps {
|
||||||
|
groupCode: string; // 계층 그룹 코드
|
||||||
|
level: number; // 현재 레벨 (1, 2, 3...)
|
||||||
|
parentValue?: string; // 부모 값 (레벨 2 이상)
|
||||||
|
value?: string; // 선택된 값
|
||||||
|
onChange: (value: string, option: HierarchyOption) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시 (지역 계층)
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={1} onChange={setCountry} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={2} parentValue={country} onChange={setProvince} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={3} parentValue={province} onChange={setCity} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 CascadingHierarchyGroup (자동 연결)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyGroupProps {
|
||||||
|
groupCode: string;
|
||||||
|
values: Record<number, string>; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' }
|
||||||
|
onChange: (level: number, value: string) => void;
|
||||||
|
layout?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<CascadingHierarchyGroup
|
||||||
|
groupCode="REGION_HIERARCHY"
|
||||||
|
values={regionValues}
|
||||||
|
onChange={(level, value) => {
|
||||||
|
setRegionValues(prev => ({ ...prev, [level]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 BomTreeSelect (BOM 전용)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BomTreeSelectProps {
|
||||||
|
groupCode: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string, path: BomOption[]) => void;
|
||||||
|
showQty?: boolean; // 수량 표시
|
||||||
|
showLevel?: boolean; // 레벨 표시
|
||||||
|
maxDepth?: number; // 최대 깊이 제한
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<BomTreeSelect
|
||||||
|
groupCode="PRODUCT_BOM"
|
||||||
|
value={selectedPart}
|
||||||
|
onChange={(value, path) => {
|
||||||
|
setSelectedPart(value);
|
||||||
|
console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품]
|
||||||
|
}}
|
||||||
|
showQty
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 화면관리 시스템 통합
|
||||||
|
|
||||||
|
### 6.1 컴포넌트 설정 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SelectBasicConfig {
|
||||||
|
// 기존 설정
|
||||||
|
cascadingEnabled?: boolean;
|
||||||
|
cascadingRelationCode?: string; // 기존 2단계 관계
|
||||||
|
cascadingRole?: 'parent' | 'child';
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
|
// 🆕 레벨 기반 계층 설정
|
||||||
|
hierarchyEnabled?: boolean;
|
||||||
|
hierarchyGroupCode?: string; // 계층 그룹 코드
|
||||||
|
hierarchyLevel?: number; // 이 컴포넌트의 레벨
|
||||||
|
hierarchyParentField?: string; // 부모 레벨 필드명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 설정 UI 확장
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 설정 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ○ 2단계 관계 (기존) │
|
||||||
|
│ └─ 관계 선택: [창고-위치 ▼] │
|
||||||
|
│ └─ 역할: [부모] [자식] │
|
||||||
|
│ │
|
||||||
|
│ ● 다단계 계층 (신규) │
|
||||||
|
│ └─ 계층 그룹: [지역 계층 ▼] │
|
||||||
|
│ └─ 레벨: [2 - 시/도 ▼] │
|
||||||
|
│ └─ 부모 필드: [country_code] (자동감지) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 기반 구축
|
||||||
|
1. ✅ 기존 2단계 연쇄 드롭다운 완성
|
||||||
|
2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql)
|
||||||
|
3. 📋 백엔드 API 구현 (계층 그룹 CRUD)
|
||||||
|
|
||||||
|
### Phase 2: MULTI_TABLE 지원
|
||||||
|
1. 📋 레벨 관리 API
|
||||||
|
2. 📋 옵션 조회 API
|
||||||
|
3. 📋 프론트엔드 컴포넌트
|
||||||
|
|
||||||
|
### Phase 3: SELF_REFERENCE 지원
|
||||||
|
1. 📋 자기참조 쿼리 로직
|
||||||
|
2. 📋 code_info 기반 카테고리 계층
|
||||||
|
|
||||||
|
### Phase 4: BOM/TREE 지원
|
||||||
|
1. 📋 BOM 전용 API
|
||||||
|
2. 📋 트리 컴포넌트
|
||||||
|
3. 📋 무한 깊이 지원
|
||||||
|
|
||||||
|
### Phase 5: 화면관리 통합
|
||||||
|
1. 📋 설정 UI 확장
|
||||||
|
2. 📋 자동 연결 기능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 성능 고려사항
|
||||||
|
|
||||||
|
### 8.1 쿼리 최적화
|
||||||
|
- 인덱스: `(group_code, company_code, level_order)`
|
||||||
|
- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱
|
||||||
|
- Lazy Loading: 하위 레벨은 필요 시에만 로드
|
||||||
|
|
||||||
|
### 8.2 BOM 재귀 쿼리
|
||||||
|
```sql
|
||||||
|
-- PostgreSQL WITH RECURSIVE 활용
|
||||||
|
WITH RECURSIVE bom_tree AS (
|
||||||
|
-- 루트 노드
|
||||||
|
SELECT id, pid, qty, 1 AS level
|
||||||
|
FROM klbom_tbl
|
||||||
|
WHERE pid IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 하위 노드
|
||||||
|
SELECT b.id, b.pid, b.qty, t.level + 1
|
||||||
|
FROM klbom_tbl b
|
||||||
|
JOIN bom_tree t ON b.pid = t.id
|
||||||
|
WHERE t.level < 10 -- 최대 깊이 제한
|
||||||
|
)
|
||||||
|
SELECT * FROM bom_tree;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 트리 최적화 전략
|
||||||
|
- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1`
|
||||||
|
- Nested Set: left/right 값으로 범위 쿼리
|
||||||
|
- Closure Table: 별도 관계 테이블
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 추가 연쇄 패턴
|
||||||
|
|
||||||
|
### 9.1 조건부 연쇄 (Conditional Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시
|
||||||
|
|
||||||
|
```
|
||||||
|
입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시
|
||||||
|
입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_condition`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, company_code
|
||||||
|
) VALUES
|
||||||
|
('WAREHOUSE_LOCATION', '구매입고 창고',
|
||||||
|
'inbound_type', 'EQ', 'PURCHASE',
|
||||||
|
'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 다중 부모 연쇄 (Multi-Parent Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링
|
||||||
|
|
||||||
|
```
|
||||||
|
회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 관계 정의
|
||||||
|
INSERT INTO cascading_multi_parent (
|
||||||
|
relation_code, relation_name,
|
||||||
|
child_table, child_value_column, child_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'COMPANY_DIVISION_DEPT', '회사-사업부-부서',
|
||||||
|
'dept_info', 'dept_code', 'dept_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 부모 소스 정의
|
||||||
|
INSERT INTO cascading_multi_parent_source (
|
||||||
|
relation_code, company_code, parent_order, parent_name,
|
||||||
|
parent_table, parent_value_column, child_filter_column
|
||||||
|
) VALUES
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'),
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 자동 입력 그룹 (Auto-Fill Group)
|
||||||
|
|
||||||
|
**사용 사례**: 마스터 선택 시 여러 필드 자동 입력
|
||||||
|
|
||||||
|
```
|
||||||
|
고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name,
|
||||||
|
master_table, master_value_column, master_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력',
|
||||||
|
'customer_info', 'customer_code', 'customer_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 필드 매핑
|
||||||
|
INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label
|
||||||
|
) VALUES
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.4 상호 배제 (Mutual Exclusion)
|
||||||
|
|
||||||
|
**사용 사례**: 같은 값 선택 불가
|
||||||
|
|
||||||
|
```
|
||||||
|
출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외)
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_mutual_exclusion`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
error_message, company_code
|
||||||
|
) VALUES (
|
||||||
|
'WAREHOUSE_TRANSFER', '창고간 이동',
|
||||||
|
'from_warehouse_code,to_warehouse_code',
|
||||||
|
'warehouse_info', 'warehouse_code', 'warehouse_name',
|
||||||
|
'출발 창고와 도착 창고는 같을 수 없습니다',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.5 역방향 조회 (Reverse Lookup)
|
||||||
|
|
||||||
|
**사용 사례**: 자식에서 부모 방향으로 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_reverse_lookup`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_reverse_lookup (
|
||||||
|
lookup_code, lookup_name,
|
||||||
|
source_table, source_value_column, source_label_column,
|
||||||
|
target_table, target_value_column, target_label_column, target_link_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ITEM_USED_IN_BOM', '품목 사용처 BOM',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'klbom_tbl', 'pid', 'ayupgname', 'id',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 전체 테이블 구조 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 시스템 구조 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [기존 - 2단계] │
|
||||||
|
│ cascading_relation ─────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다단계 계층] │
|
||||||
|
│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │
|
||||||
|
│ │ (MULTI_TABLE용) │
|
||||||
|
│ │ │
|
||||||
|
│ [신규 - 조건부] │
|
||||||
|
│ cascading_condition ────────┴── 조건에 따른 필터링 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다중 부모] │
|
||||||
|
│ cascading_multi_parent ─────┬── cascading_multi_parent_source │
|
||||||
|
│ │ (여러 부모 조합) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 자동 입력] │
|
||||||
|
│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │
|
||||||
|
│ │ (마스터→다중 필드) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 상호 배제] │
|
||||||
|
│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 역방향] │
|
||||||
|
│ cascading_reverse_lookup ───┴── 자식→부모 조회 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 마이그레이션 가이드
|
||||||
|
|
||||||
|
### 11.1 기존 데이터 마이그레이션
|
||||||
|
```sql
|
||||||
|
-- 기존 cascading_relation → cascading_hierarchy_group 변환
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'LEGACY_' || relation_code,
|
||||||
|
relation_name,
|
||||||
|
'MULTI_TABLE',
|
||||||
|
2,
|
||||||
|
company_code
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE is_active = 'Y';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 호환성 유지
|
||||||
|
- 기존 `cascading_relation` 테이블 유지
|
||||||
|
- 기존 API 엔드포인트 유지
|
||||||
|
- 점진적 마이그레이션 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 우선순위 (업데이트)
|
||||||
|
|
||||||
|
| Phase | 기능 | 복잡도 | 우선순위 |
|
||||||
|
|-------|------|--------|----------|
|
||||||
|
| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 |
|
||||||
|
| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 |
|
||||||
|
| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 |
|
||||||
|
| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 |
|
||||||
|
| 5 | 조건부 연쇄 | 중 | 중간 |
|
||||||
|
| 6 | 상호 배제 | 낮음 | 중간 |
|
||||||
|
| 7 | 다중 부모 연쇄 | 높음 | 낮음 |
|
||||||
|
| 8 | BOM/TREE 구조 | 높음 | 낮음 |
|
||||||
|
| 9 | 역방향 조회 | 중 | 낮음 |
|
||||||
|
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
# 메일 발송 기능 사용 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
노드 기반 제어관리 시스템을 통해 메일을 발송하는 방법을 설명합니다.
|
||||||
|
화면에서 데이터를 선택하고, 수신자를 지정하여 템플릿 기반의 메일을 발송할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사전 준비
|
||||||
|
|
||||||
|
### 1.1 메일 계정 등록
|
||||||
|
|
||||||
|
메일 발송을 위해 먼저 SMTP 계정을 등록해야 합니다.
|
||||||
|
|
||||||
|
1. **관리자** > **메일관리** > **계정관리** 이동
|
||||||
|
2. **새 계정 추가** 클릭
|
||||||
|
3. SMTP 정보 입력:
|
||||||
|
- 계정명: 식별용 이름 (예: "회사 공식 메일")
|
||||||
|
- 이메일: 발신자 이메일 주소
|
||||||
|
- SMTP 호스트: 메일 서버 주소 (예: smtp.gmail.com)
|
||||||
|
- SMTP 포트: 포트 번호 (예: 587)
|
||||||
|
- 보안: TLS/SSL 선택
|
||||||
|
- 사용자명/비밀번호: SMTP 인증 정보
|
||||||
|
4. **저장** 후 **테스트 발송**으로 동작 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 제어관리 설정
|
||||||
|
|
||||||
|
### 2.1 메일 발송 플로우 생성
|
||||||
|
|
||||||
|
**관리자** > **제어관리** > **플로우 관리**에서 새 플로우를 생성합니다.
|
||||||
|
|
||||||
|
#### 기본 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스] → [메일 발송]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 노드 구성
|
||||||
|
|
||||||
|
1. **테이블 소스 노드** 추가
|
||||||
|
|
||||||
|
- 데이터 소스: **컨텍스트 데이터** (화면에서 선택한 데이터 사용)
|
||||||
|
- 또는 **테이블 전체 데이터** (주의: 전체 데이터 건수만큼 메일 발송)
|
||||||
|
|
||||||
|
2. **메일 발송 노드** 추가
|
||||||
|
|
||||||
|
- 노드 팔레트 > 외부 실행 > **메일 발송** 드래그
|
||||||
|
|
||||||
|
3. 두 노드 연결 (테이블 소스 → 메일 발송)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 메일 발송 노드 설정
|
||||||
|
|
||||||
|
메일 발송 노드를 클릭하면 우측에 속성 패널이 표시됩니다.
|
||||||
|
|
||||||
|
#### 계정 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| -------------- | ----------------------------------- |
|
||||||
|
| 발송 계정 선택 | 사전에 등록한 메일 계정 선택 (필수) |
|
||||||
|
|
||||||
|
#### 메일 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| -------------------- | ------------------------------------------------ |
|
||||||
|
| 수신자 컴포넌트 사용 | 체크 시 화면의 수신자 선택 컴포넌트 값 자동 사용 |
|
||||||
|
| 수신자 필드명 | 수신자 변수명 (기본: mailTo) |
|
||||||
|
| 참조 필드명 | 참조 변수명 (기본: mailCc) |
|
||||||
|
| 수신자 (To) | 직접 입력 또는 변수 사용 (예: `{{email}}`) |
|
||||||
|
| 참조 (CC) | 참조 수신자 |
|
||||||
|
| 숨은 참조 (BCC) | 숨은 참조 수신자 |
|
||||||
|
| 우선순위 | 높음 / 보통 / 낮음 |
|
||||||
|
|
||||||
|
#### 본문 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| --------- | -------------------------------- |
|
||||||
|
| 제목 | 메일 제목 (변수 사용 가능) |
|
||||||
|
| 본문 형식 | 텍스트 (변수 태그 에디터) / HTML |
|
||||||
|
| 본문 내용 | 메일 본문 (변수 사용 가능) |
|
||||||
|
|
||||||
|
#### 옵션 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| ----------- | ------------------- |
|
||||||
|
| 타임아웃 | 발송 제한 시간 (ms) |
|
||||||
|
| 재시도 횟수 | 실패 시 재시도 횟수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 변수 사용 방법
|
||||||
|
|
||||||
|
메일 제목과 본문에서 `{{변수명}}` 형식으로 데이터 필드를 참조할 수 있습니다.
|
||||||
|
|
||||||
|
#### 텍스트 모드 (변수 태그 에디터)
|
||||||
|
|
||||||
|
1. 본문 형식을 **텍스트 (변수 태그 에디터)** 선택
|
||||||
|
2. 에디터에서 `@` 또는 `/` 키 입력
|
||||||
|
3. 변수 목록에서 원하는 변수 선택
|
||||||
|
4. 선택된 변수는 파란색 태그로 표시
|
||||||
|
|
||||||
|
#### HTML 모드 (직접 입력)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>주문 확인</h1>
|
||||||
|
<p>안녕하세요 {{customerName}}님,</p>
|
||||||
|
<p>주문번호 {{orderNo}}의 주문이 완료되었습니다.</p>
|
||||||
|
<p>금액: {{totalAmount}}원</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 사용 가능한 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| ---------------- | ------------------------ |
|
||||||
|
| `{{timestamp}}` | 메일 발송 시점 |
|
||||||
|
| `{{sourceData}}` | 전체 소스 데이터 (JSON) |
|
||||||
|
| `{{필드명}}` | 테이블 소스의 각 컬럼 값 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 화면 구성
|
||||||
|
|
||||||
|
### 3.1 기본 구조
|
||||||
|
|
||||||
|
메일 발송 화면은 보통 다음과 같이 구성합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
[부모 화면: 데이터 목록]
|
||||||
|
↓ (모달 열기 버튼)
|
||||||
|
[모달: 수신자 입력 + 발송 버튼]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 수신자 선택 컴포넌트 배치
|
||||||
|
|
||||||
|
1. **화면관리**에서 모달 화면 편집
|
||||||
|
2. 컴포넌트 팔레트 > **메일 수신자 선택** 드래그
|
||||||
|
3. 컴포넌트 설정:
|
||||||
|
- 수신자 필드명: `mailTo` (메일 발송 노드와 일치)
|
||||||
|
- 참조 필드명: `mailCc` (메일 발송 노드와 일치)
|
||||||
|
|
||||||
|
#### 수신자 선택 기능
|
||||||
|
|
||||||
|
- **내부 사용자**: 회사 직원 목록에서 검색/선택
|
||||||
|
- **외부 이메일**: 직접 이메일 주소 입력
|
||||||
|
- 여러 명 선택 가능 (쉼표로 구분)
|
||||||
|
|
||||||
|
### 3.3 발송 버튼 설정
|
||||||
|
|
||||||
|
1. **버튼** 컴포넌트 추가
|
||||||
|
2. 버튼 설정:
|
||||||
|
- 액션 타입: **제어 실행**
|
||||||
|
- 플로우 선택: 생성한 메일 발송 플로우
|
||||||
|
- 데이터 소스: **자동** 또는 **폼 + 테이블 선택**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 전체 흐름 예시
|
||||||
|
|
||||||
|
### 4.1 시나리오: 선택한 주문 건에 대해 고객에게 메일 발송
|
||||||
|
|
||||||
|
#### Step 1: 제어관리 플로우 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스: 컨텍스트 데이터]
|
||||||
|
↓
|
||||||
|
[메일 발송]
|
||||||
|
- 계정: 회사 공식 메일
|
||||||
|
- 수신자 컴포넌트 사용: 체크
|
||||||
|
- 제목: [주문확인] {{orderNo}} 주문이 완료되었습니다
|
||||||
|
- 본문:
|
||||||
|
안녕하세요 {{customerName}}님,
|
||||||
|
|
||||||
|
주문번호 {{orderNo}}의 주문이 정상 처리되었습니다.
|
||||||
|
|
||||||
|
- 상품명: {{productName}}
|
||||||
|
- 수량: {{quantity}}
|
||||||
|
- 금액: {{totalAmount}}원
|
||||||
|
|
||||||
|
감사합니다.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 부모 화면 (주문 목록)
|
||||||
|
|
||||||
|
- 주문 데이터 테이블
|
||||||
|
- "메일 발송" 버튼
|
||||||
|
- 액션: 모달 열기
|
||||||
|
- 모달 화면: 메일 발송 모달
|
||||||
|
- 선택된 데이터 전달: 체크
|
||||||
|
|
||||||
|
#### Step 3: 모달 화면 (메일 발송)
|
||||||
|
|
||||||
|
- 메일 수신자 선택 컴포넌트
|
||||||
|
- 수신자 (To) 입력
|
||||||
|
- 참조 (CC) 입력
|
||||||
|
- "발송" 버튼
|
||||||
|
- 액션: 제어 실행
|
||||||
|
- 플로우: 메일 발송 플로우
|
||||||
|
|
||||||
|
#### Step 4: 실행 흐름
|
||||||
|
|
||||||
|
1. 사용자가 주문 목록에서 주문 선택
|
||||||
|
2. "메일 발송" 버튼 클릭 → 모달 열림
|
||||||
|
3. 수신자/참조 입력
|
||||||
|
4. "발송" 버튼 클릭
|
||||||
|
5. 제어 실행:
|
||||||
|
- 부모 화면 데이터 (orderNo, customerName 등) + 모달 폼 데이터 (mailTo, mailCc) 병합
|
||||||
|
- 변수 치환 후 메일 발송
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터 소스별 동작
|
||||||
|
|
||||||
|
### 5.1 컨텍스트 데이터 (권장)
|
||||||
|
|
||||||
|
- 화면에서 **선택한 데이터**만 사용
|
||||||
|
- 선택한 건수만큼 메일 발송
|
||||||
|
|
||||||
|
| 선택 건수 | 메일 발송 수 |
|
||||||
|
| --------- | ------------ |
|
||||||
|
| 1건 | 1통 |
|
||||||
|
| 5건 | 5통 |
|
||||||
|
| 10건 | 10통 |
|
||||||
|
|
||||||
|
### 5.2 테이블 전체 데이터 (주의)
|
||||||
|
|
||||||
|
- 테이블의 **모든 데이터** 사용
|
||||||
|
- 전체 건수만큼 메일 발송
|
||||||
|
|
||||||
|
| 테이블 데이터 | 메일 발송 수 |
|
||||||
|
| ------------- | ------------ |
|
||||||
|
| 100건 | 100통 |
|
||||||
|
| 1000건 | 1000통 |
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
|
||||||
|
- 대량 발송 시 SMTP 서버 rate limit 주의
|
||||||
|
- 테스트 시 반드시 데이터 건수 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 문제 해결
|
||||||
|
|
||||||
|
### 6.1 메일이 발송되지 않음
|
||||||
|
|
||||||
|
1. **계정 설정 확인**: 메일관리 > 계정관리에서 테스트 발송 확인
|
||||||
|
2. **수신자 확인**: 수신자 이메일 주소가 올바른지 확인
|
||||||
|
3. **플로우 연결 확인**: 테이블 소스 → 메일 발송 노드가 연결되어 있는지 확인
|
||||||
|
|
||||||
|
### 6.2 변수가 치환되지 않음
|
||||||
|
|
||||||
|
1. **변수명 확인**: `{{변수명}}`에서 변수명이 테이블 컬럼명과 일치하는지 확인
|
||||||
|
2. **데이터 소스 확인**: 테이블 소스 노드가 올바른 데이터를 가져오는지 확인
|
||||||
|
3. **데이터 전달 확인**: 부모 화면 → 모달로 데이터가 전달되는지 확인
|
||||||
|
|
||||||
|
### 6.3 수신자 컴포넌트 값이 전달되지 않음
|
||||||
|
|
||||||
|
1. **필드명 일치 확인**:
|
||||||
|
- 수신자 컴포넌트의 필드명과 메일 발송 노드의 필드명이 일치해야 함
|
||||||
|
- 기본값: `mailTo`, `mailCc`
|
||||||
|
2. **수신자 컴포넌트 사용 체크**: 메일 발송 노드에서 "수신자 컴포넌트 사용" 활성화
|
||||||
|
|
||||||
|
### 6.4 부모 화면 데이터가 메일에 포함되지 않음
|
||||||
|
|
||||||
|
1. **모달 열기 설정 확인**: "선택된 데이터 전달" 옵션 활성화
|
||||||
|
2. **데이터 소스 설정 확인**: 발송 버튼의 데이터 소스가 "자동" 또는 "폼 + 테이블 선택"인지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 고급 기능
|
||||||
|
|
||||||
|
### 7.1 조건부 메일 발송
|
||||||
|
|
||||||
|
조건 분기 노드를 사용하여 특정 조건에서만 메일을 발송할 수 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스]
|
||||||
|
↓
|
||||||
|
[조건 분기: status === 'approved']
|
||||||
|
↓ (true)
|
||||||
|
[메일 발송: 승인 알림]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 다중 수신자 처리
|
||||||
|
|
||||||
|
수신자 필드에 쉼표로 구분하여 여러 명에게 동시 발송:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{managerEmail}}, {{teamLeadEmail}}, external@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 HTML 템플릿 활용
|
||||||
|
|
||||||
|
본문 형식을 HTML로 설정하면 풍부한 형식의 메일을 보낼 수 있습니다:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>주문 확인</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>안녕하세요 <strong>{{customerName}}</strong>님,</p>
|
||||||
|
<p>주문번호 <strong>{{orderNo}}</strong>의 주문이 완료되었습니다.</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>상품명</td>
|
||||||
|
<td>{{productName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>금액</td>
|
||||||
|
<td>{{totalAmount}}원</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">본 메일은 자동 발송되었습니다.</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 체크리스트
|
||||||
|
|
||||||
|
메일 발송 기능 구현 시 확인 사항:
|
||||||
|
|
||||||
|
- [ ] 메일 계정이 등록되어 있는가?
|
||||||
|
- [ ] 메일 계정 테스트 발송이 성공하는가?
|
||||||
|
- [ ] 제어관리에 메일 발송 플로우가 생성되어 있는가?
|
||||||
|
- [ ] 테이블 소스 노드의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
- [ ] 메일 발송 노드에서 계정이 선택되어 있는가?
|
||||||
|
- [ ] 수신자 컴포넌트 사용 시 필드명이 일치하는가?
|
||||||
|
- [ ] 변수명이 테이블 컬럼명과 일치하는가?
|
||||||
|
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
||||||
|
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default function AutoFillRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/admin/cascading-management?tab=autofill");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||||
|
|
||||||
|
// 탭별 컴포넌트
|
||||||
|
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||||
|
import AutoFillTab from "./tabs/AutoFillTab";
|
||||||
|
import HierarchyTab from "./tabs/HierarchyTab";
|
||||||
|
import ConditionTab from "./tabs/ConditionTab";
|
||||||
|
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||||
|
|
||||||
|
export default function CascadingManagementPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState("relations");
|
||||||
|
|
||||||
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = searchParams.get("tab");
|
||||||
|
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 탭 변경 시 URL 업데이트
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", value);
|
||||||
|
router.replace(url.pathname + url.search);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
|
<TabsTrigger value="relations" className="gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||||
|
<span className="sm:hidden">연쇄</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="hierarchy" className="gap-2">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">다단계 계층</span>
|
||||||
|
<span className="sm:hidden">계층</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="condition" className="gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">조건부 필터</span>
|
||||||
|
<span className="sm:hidden">조건</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="autofill" className="gap-2">
|
||||||
|
<FormInput className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">자동 입력</span>
|
||||||
|
<span className="sm:hidden">자동</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="exclusion" className="gap-2">
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">상호 배제</span>
|
||||||
|
<span className="sm:hidden">배제</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<TabsContent value="relations">
|
||||||
|
<CascadingRelationsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="hierarchy">
|
||||||
|
<HierarchyTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="condition">
|
||||||
|
<ConditionTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="autofill">
|
||||||
|
<AutoFillTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="exclusion">
|
||||||
|
<MutualExclusionTab />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,686 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ArrowRight,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableColumn {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoFillTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||||
|
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터
|
||||||
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setGroups(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("그룹 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 컬럼 로드
|
||||||
|
const loadColumns = useCallback(async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setMasterColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setMasterColumns(
|
||||||
|
response.data.columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||||
|
dataType: col.dataType || col.data_type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
setMasterColumns([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadGroups, loadTableList]);
|
||||||
|
|
||||||
|
// 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.masterTable) {
|
||||||
|
loadColumns(formData.masterTable);
|
||||||
|
}
|
||||||
|
}, [formData.masterTable, loadColumns]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
setMappings([]);
|
||||||
|
setMasterColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
|
||||||
|
// 상세 정보 로드
|
||||||
|
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detail = detailResponse.data;
|
||||||
|
|
||||||
|
// 컬럼 먼저 로드
|
||||||
|
if (detail.masterTable) {
|
||||||
|
await loadColumns(detail.masterTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
groupCode: detail.groupCode,
|
||||||
|
groupName: detail.groupName,
|
||||||
|
description: detail.description || "",
|
||||||
|
masterTable: detail.masterTable,
|
||||||
|
masterValueColumn: detail.masterValueColumn,
|
||||||
|
masterLabelColumn: detail.masterLabelColumn || "",
|
||||||
|
isActive: detail.isActive || "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||||
|
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||||
|
sourceColumn: m.source_column || m.sourceColumn,
|
||||||
|
targetField: m.target_field || m.targetField,
|
||||||
|
targetLabel: m.target_label || m.targetLabel || "",
|
||||||
|
isEditable: m.is_editable || m.isEditable || "Y",
|
||||||
|
isRequired: m.is_required || m.isRequired || "N",
|
||||||
|
defaultValue: m.default_value || m.defaultValue || "",
|
||||||
|
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||||
|
}));
|
||||||
|
setMappings(convertedMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveData = {
|
||||||
|
...formData,
|
||||||
|
mappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 추가
|
||||||
|
const handleAddMapping = () => {
|
||||||
|
setMappings([
|
||||||
|
...mappings,
|
||||||
|
{
|
||||||
|
sourceColumn: "",
|
||||||
|
targetField: "",
|
||||||
|
targetLabel: "",
|
||||||
|
isEditable: "Y",
|
||||||
|
isRequired: "N",
|
||||||
|
defaultValue: "",
|
||||||
|
sortOrder: mappings.length + 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 삭제
|
||||||
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
setMappings(mappings.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 수정
|
||||||
|
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 및 액션 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>자동 입력 그룹</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>그룹 코드</TableHead>
|
||||||
|
<TableHead>그룹명</TableHead>
|
||||||
|
<TableHead>마스터 테이블</TableHead>
|
||||||
|
<TableHead>매핑 수</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<TableRow key={group.groupCode}>
|
||||||
|
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 고객사 정보 자동입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={formData.isActive === "Y"}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||||
|
/>
|
||||||
|
<Label>활성화</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 마스터 테이블 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>마스터 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.masterTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||||
|
formData.masterTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
masterTable: table.tableName,
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
});
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
|
{table.displayName && table.displayName !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterValueColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterLabelColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||||
|
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
|
|
||||||
|
{/* 소스 컬럼 */}
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceColumn}
|
||||||
|
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
|
||||||
|
{/* 타겟 필드 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetField}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||||
|
placeholder="타겟 필드명 (예: contact_name)"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타겟 라벨 */}
|
||||||
|
<div className="w-28">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetLabel || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`editable-${index}`}
|
||||||
|
checked={mapping.isEditable === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||||
|
수정
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`required-${index}`}
|
||||||
|
checked={mapping.isRequired === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||||
|
필수
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleRemoveMapping(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,898 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CascadingRelationsTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||||||
|
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||||||
|
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 고급 설정 토글
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
||||||
|
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 조회
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName || t.name,
|
||||||
|
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컬럼 목록 조회 (수정됨)
|
||||||
|
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
setParentColumns([]);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// getColumnList 사용 (getTableColumns가 아님)
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 응답 구조: { data: { columns: [...] } }
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||||||
|
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
// 자동 추천
|
||||||
|
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||||||
|
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 컬럼 선택 (패턴 매칭)
|
||||||
|
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||||||
|
// 이미 값이 있으면 스킵
|
||||||
|
if (formData[field]) return;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||||||
|
if (found) {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRelations();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadRelations, loadTableList]);
|
||||||
|
|
||||||
|
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.parentTable) {
|
||||||
|
loadColumns(formData.parentTable, "parent");
|
||||||
|
} else {
|
||||||
|
setParentColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.parentTable, editingRelation]);
|
||||||
|
|
||||||
|
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.childTable) {
|
||||||
|
loadColumns(formData.childTable, "child");
|
||||||
|
} else {
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.childTable, editingRelation]);
|
||||||
|
|
||||||
|
// 관계 코드 자동 생성
|
||||||
|
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
return `${parent}_${child}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계명 자동 생성
|
||||||
|
const generateRelationName = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||||||
|
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||||||
|
const parentName = parentInfo?.tableLabel || parentTable;
|
||||||
|
const childName = childInfo?.tableLabel || childTable;
|
||||||
|
return `${parentName}-${childName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (신규)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingRelation(null);
|
||||||
|
setFormData({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
setParentColumns([]);
|
||||||
|
setChildColumns([]);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||||||
|
setEditingRelation(relation);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
|
||||||
|
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||||||
|
const loadPromises: Promise<void>[] = [];
|
||||||
|
if (relation.parent_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||||||
|
}
|
||||||
|
if (relation.child_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 로드 완료 대기
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
|
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||||||
|
setFormData({
|
||||||
|
relationCode: relation.relation_code,
|
||||||
|
relationName: relation.relation_name,
|
||||||
|
description: relation.description || "",
|
||||||
|
parentTable: relation.parent_table,
|
||||||
|
parentValueColumn: relation.parent_value_column,
|
||||||
|
parentLabelColumn: relation.parent_label_column || "",
|
||||||
|
childTable: relation.child_table,
|
||||||
|
childFilterColumn: relation.child_filter_column,
|
||||||
|
childValueColumn: relation.child_value_column,
|
||||||
|
childLabelColumn: relation.child_label_column,
|
||||||
|
childOrderColumn: relation.child_order_column || "",
|
||||||
|
childOrderDirection: relation.child_order_direction || "ASC",
|
||||||
|
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: relation.loading_message || "로딩 중...",
|
||||||
|
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 테이블 선택 시 자동 설정
|
||||||
|
const handleParentTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.parentTable;
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parentTable: value,
|
||||||
|
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||||||
|
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "parent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자식 테이블 선택 시 자동 설정
|
||||||
|
const handleChildTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.childTable;
|
||||||
|
|
||||||
|
const newFormData = {
|
||||||
|
...formData,
|
||||||
|
childTable: value,
|
||||||
|
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||||||
|
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||||||
|
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||||||
|
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||||||
|
if (!editingRelation) {
|
||||||
|
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||||||
|
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(newFormData);
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "child");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!formData.parentTable || !formData.parentValueColumn) {
|
||||||
|
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!formData.childTable ||
|
||||||
|
!formData.childFilterColumn ||
|
||||||
|
!formData.childValueColumn ||
|
||||||
|
!formData.childLabelColumn
|
||||||
|
) {
|
||||||
|
toast.error("자식 테이블 설정을 완료해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (비어있으면)
|
||||||
|
const finalData = { ...formData };
|
||||||
|
if (!finalData.relationCode) {
|
||||||
|
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
if (!finalData.relationName) {
|
||||||
|
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingRelation) {
|
||||||
|
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingRelationApi.create(finalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = async (relation: CascadingRelation) => {
|
||||||
|
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("연쇄 관계가 삭제되었습니다.");
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 목록
|
||||||
|
const filteredRelations = relations.filter(
|
||||||
|
(r) =>
|
||||||
|
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 셀렉트 렌더링 헬퍼
|
||||||
|
const renderColumnSelect = (
|
||||||
|
value: string,
|
||||||
|
onChange: (v: string) => void,
|
||||||
|
columns: ColumnInfo[],
|
||||||
|
loading: boolean,
|
||||||
|
placeholder: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
) => (
|
||||||
|
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span className="text-xs">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||||||
|
) : (
|
||||||
|
columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{col.columnLabel}</span>
|
||||||
|
{col.columnLabel !== col.columnName && (
|
||||||
|
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
2단계 연쇄 관계
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>부모-자식 관계로 연결된 드롭다운을 정의합니다. (예: 창고 → 위치)</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>관계명</TableHead>
|
||||||
|
<TableHead>연결</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="w-[100px]">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredRelations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRelations.map((relation) => (
|
||||||
|
<TableRow key={relation.relation_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{relation.relation_name}</div>
|
||||||
|
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||||||
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||||||
|
{relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||||||
|
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 - 간소화된 UI */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||||||
|
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1: 부모 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={parentTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{loadingTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: formData.parentTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
||||||
|
formData.parentTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleParentTableChange(table.tableName);
|
||||||
|
setParentTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.parentValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||||||
|
parentColumns,
|
||||||
|
loadingParentColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.parentTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: 자식 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={childTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
disabled={!formData.parentTable}
|
||||||
|
>
|
||||||
|
{formData.childTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
||||||
|
formData.childTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChildTableChange(table.tableName);
|
||||||
|
setChildTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childFilterColumn,
|
||||||
|
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childLabelColumn,
|
||||||
|
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관계 정보 (자동 생성) */}
|
||||||
|
{formData.parentTable && formData.childTable && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계 코드</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={!!editingRelation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 고급 설정 토글 */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span>고급 설정</span>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 관계에 대한 설명..."
|
||||||
|
rows={2}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">상위 미선택 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.emptyParentMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">옵션 없음 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.noOptionsMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.clearOnParentChange}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : editingRelation ? (
|
||||||
|
"수정"
|
||||||
|
) : (
|
||||||
|
"생성"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
cascadingConditionApi,
|
||||||
|
CascadingCondition,
|
||||||
|
CONDITION_OPERATORS,
|
||||||
|
} from "@/lib/api/cascadingCondition";
|
||||||
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
|
export default function ConditionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||||
|
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||||
|
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadConditions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setConditions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("조건 목록 로드 실패:", error);
|
||||||
|
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConditions();
|
||||||
|
loadRelations();
|
||||||
|
}, [loadConditions, loadRelations]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredConditions = conditions.filter(
|
||||||
|
(c) =>
|
||||||
|
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingCondition(null);
|
||||||
|
setFormData({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||||
|
setEditingCondition(condition);
|
||||||
|
setFormData({
|
||||||
|
relationType: condition.relationType || "RELATION",
|
||||||
|
relationCode: condition.relationCode,
|
||||||
|
conditionName: condition.conditionName,
|
||||||
|
conditionField: condition.conditionField,
|
||||||
|
conditionOperator: condition.conditionOperator,
|
||||||
|
conditionValue: condition.conditionValue,
|
||||||
|
filterColumn: condition.filterColumn,
|
||||||
|
filterValues: condition.filterValues,
|
||||||
|
priority: condition.priority || 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (conditionId: number) => {
|
||||||
|
setDeletingConditionId(conditionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingConditionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingConditionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||||
|
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingCondition) {
|
||||||
|
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingConditionApi.create(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연산자 라벨 찾기
|
||||||
|
const getOperatorLabel = (operator: string) => {
|
||||||
|
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadConditions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
조건부 필터 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredConditions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 품목 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"상태" 필드가 "활성"일 때만 "품목" 드롭다운에 활성 품목만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 옵션 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"유형" 필드가 "입고"일 때 "창고" 드롭다운에 입고 가능 창고만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>연쇄 관계</TableHead>
|
||||||
|
<TableHead>조건명</TableHead>
|
||||||
|
<TableHead>조건</TableHead>
|
||||||
|
<TableHead>필터</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredConditions.map((condition) => (
|
||||||
|
<TableRow key={condition.conditionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||||
|
<span className="mx-1 text-blue-600">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||||
|
<span className="font-medium">{condition.conditionValue}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||||
|
<span className="mx-1">=</span>
|
||||||
|
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 연쇄 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>연쇄 관계 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.relationCode}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="연쇄 관계 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relations.map((rel) => (
|
||||||
|
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||||
|
{rel.relation_name} ({rel.relation_code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>조건명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||||
|
placeholder="예: 활성 품목만 표시"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionField}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">연산자 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.conditionOperator}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONDITION_OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionValue}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||||
|
placeholder="예: active"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterColumn}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterValues}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||||
|
placeholder="예: active,pending"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우선순위 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>우선순위</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||||
|
placeholder="높을수록 먼저 적용"
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>조건부 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 조건부 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,847 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function HierarchyTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 확장된 그룹 (레벨 표시)
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 레벨 모달
|
||||||
|
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||||||
|
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||||||
|
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||||||
|
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레벨 폼 데이터
|
||||||
|
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||||||
|
levelOrder: 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// snake_case를 camelCase로 변환하는 함수
|
||||||
|
const transformGroup = (g: any): HierarchyGroup => ({
|
||||||
|
groupId: g.group_id || g.groupId,
|
||||||
|
groupCode: g.group_code || g.groupCode,
|
||||||
|
groupName: g.group_name || g.groupName,
|
||||||
|
description: g.description,
|
||||||
|
hierarchyType: g.hierarchy_type || g.hierarchyType,
|
||||||
|
maxLevels: g.max_levels || g.maxLevels,
|
||||||
|
isFixedLevels: g.is_fixed_levels || g.isFixedLevels,
|
||||||
|
selfRefTable: g.self_ref_table || g.selfRefTable,
|
||||||
|
selfRefIdColumn: g.self_ref_id_column || g.selfRefIdColumn,
|
||||||
|
selfRefParentColumn: g.self_ref_parent_column || g.selfRefParentColumn,
|
||||||
|
selfRefValueColumn: g.self_ref_value_column || g.selfRefValueColumn,
|
||||||
|
selfRefLabelColumn: g.self_ref_label_column || g.selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn: g.self_ref_level_column || g.selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn: g.self_ref_order_column || g.selfRefOrderColumn,
|
||||||
|
bomTable: g.bom_table || g.bomTable,
|
||||||
|
bomParentColumn: g.bom_parent_column || g.bomParentColumn,
|
||||||
|
bomChildColumn: g.bom_child_column || g.bomChildColumn,
|
||||||
|
bomItemTable: g.bom_item_table || g.bomItemTable,
|
||||||
|
bomItemIdColumn: g.bom_item_id_column || g.bomItemIdColumn,
|
||||||
|
bomItemLabelColumn: g.bom_item_label_column || g.bomItemLabelColumn,
|
||||||
|
bomQtyColumn: g.bom_qty_column || g.bomQtyColumn,
|
||||||
|
bomLevelColumn: g.bom_level_column || g.bomLevelColumn,
|
||||||
|
emptyMessage: g.empty_message || g.emptyMessage,
|
||||||
|
noOptionsMessage: g.no_options_message || g.noOptionsMessage,
|
||||||
|
loadingMessage: g.loading_message || g.loadingMessage,
|
||||||
|
companyCode: g.company_code || g.companyCode,
|
||||||
|
isActive: g.is_active || g.isActive,
|
||||||
|
createdBy: g.created_by || g.createdBy,
|
||||||
|
createdDate: g.created_date || g.createdDate,
|
||||||
|
updatedBy: g.updated_by || g.updatedBy,
|
||||||
|
updatedDate: g.updated_date || g.updatedDate,
|
||||||
|
levelCount: g.level_count || g.levelCount || 0,
|
||||||
|
levels: g.levels,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// snake_case를 camelCase로 변환
|
||||||
|
const transformedData = response.data.map(transformGroup);
|
||||||
|
setGroups(transformedData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("계층 그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTables();
|
||||||
|
}, [loadGroups, loadTables]);
|
||||||
|
|
||||||
|
// 그룹 레벨 로드
|
||||||
|
const loadGroupLevels = async (groupCode: string) => {
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getDetail(groupCode);
|
||||||
|
if (response.success && response.data?.levels) {
|
||||||
|
setGroupLevels((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[groupCode]: response.data!.levels || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레벨 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 확장 토글
|
||||||
|
const toggleGroupExpand = async (groupCode: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups);
|
||||||
|
if (newExpanded.has(groupCode)) {
|
||||||
|
newExpanded.delete(groupCode);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(groupCode);
|
||||||
|
if (!groupLevels[groupCode]) {
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 로드 (레벨 폼용)
|
||||||
|
const loadLevelColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setLevelColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setLevelColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (group: HierarchyGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
setFormData({
|
||||||
|
groupCode: group.groupCode,
|
||||||
|
groupName: group.groupName,
|
||||||
|
description: group.description || "",
|
||||||
|
hierarchyType: group.hierarchyType,
|
||||||
|
maxLevels: group.maxLevels,
|
||||||
|
isFixedLevels: group.isFixedLevels || "Y",
|
||||||
|
emptyMessage: group.emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage: group.loadingMessage || "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.groupName || !formData.hierarchyType) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (생성)
|
||||||
|
const handleOpenCreateLevel = (groupCode: string) => {
|
||||||
|
setCurrentGroupCode(groupCode);
|
||||||
|
setEditingLevel(null);
|
||||||
|
const existingLevels = groupLevels[groupCode] || [];
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: existingLevels.length + 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
setLevelColumns([]);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (수정)
|
||||||
|
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||||||
|
setCurrentGroupCode(level.groupCode);
|
||||||
|
setEditingLevel(level);
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: level.levelOrder,
|
||||||
|
levelName: level.levelName,
|
||||||
|
levelCode: level.levelCode || "",
|
||||||
|
tableName: level.tableName,
|
||||||
|
valueColumn: level.valueColumn,
|
||||||
|
labelColumn: level.labelColumn,
|
||||||
|
parentKeyColumn: level.parentKeyColumn || "",
|
||||||
|
orderColumn: level.orderColumn || "",
|
||||||
|
orderDirection: level.orderDirection || "ASC",
|
||||||
|
placeholder: level.placeholder || "",
|
||||||
|
isRequired: level.isRequired || "Y",
|
||||||
|
isSearchable: level.isSearchable || "N",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(level.tableName);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 저장
|
||||||
|
const handleSaveLevel = async () => {
|
||||||
|
if (
|
||||||
|
!levelFormData.levelName ||
|
||||||
|
!levelFormData.tableName ||
|
||||||
|
!levelFormData.valueColumn ||
|
||||||
|
!levelFormData.labelColumn
|
||||||
|
) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingLevel) {
|
||||||
|
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||||||
|
setIsLevelModalOpen(false);
|
||||||
|
await loadGroupLevels(currentGroupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||||||
|
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteLevel(levelId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("레벨이 삭제되었습니다.");
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계층 타입 라벨
|
||||||
|
const getHierarchyTypeLabel = (type: string) => {
|
||||||
|
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
다단계 계층
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<div key={group.groupCode} className="rounded-lg border">
|
||||||
|
<div
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||||||
|
onClick={() => toggleGroupExpand(group.groupCode)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedGroups.has(group.groupCode) ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{group.groupName}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||||||
|
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레벨 목록 */}
|
||||||
|
{expandedGroups.has(group.groupCode) && (
|
||||||
|
<div className="bg-muted/20 border-t p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">레벨 목록</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
레벨 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">순서</TableHead>
|
||||||
|
<TableHead>레벨명</TableHead>
|
||||||
|
<TableHead>테이블</TableHead>
|
||||||
|
<TableHead>값 컬럼</TableHead>
|
||||||
|
<TableHead>부모 키</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(groupLevels[group.groupCode] || []).map((level) => (
|
||||||
|
<TableRow key={level.levelId}>
|
||||||
|
<TableCell>{level.levelOrder}</TableCell>
|
||||||
|
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 그룹 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 지역 계층"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>계층 유형 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.hierarchyType}
|
||||||
|
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HIERARCHY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="계층 구조에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>최대 레벨 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.maxLevels || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
placeholder="예: 4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>고정 레벨 여부</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.isFixedLevels}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">고정</SelectItem>
|
||||||
|
<SelectItem value="N">가변</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 레벨 생성/수정 모달 */}
|
||||||
|
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||||||
|
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨 순서 *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={levelFormData.levelOrder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.levelName}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||||||
|
placeholder="예: 시/도"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{levelFormData.tableName
|
||||||
|
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||||||
|
levelFormData.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setLevelFormData({
|
||||||
|
...levelFormData,
|
||||||
|
tableName: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.valueColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.labelColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.parentKeyColumn || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||||||
|
}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>플레이스홀더</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.placeholder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||||||
|
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,582 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function MutualExclusionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||||
|
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필드 목록 (동적 추가)
|
||||||
|
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadExclusions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setExclusions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("상호 배제 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExclusions();
|
||||||
|
loadTables();
|
||||||
|
}, [loadExclusions, loadTables]);
|
||||||
|
|
||||||
|
// 테이블 선택 시 컬럼 로드
|
||||||
|
const loadColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredExclusions = exclusions.filter(
|
||||||
|
(e) =>
|
||||||
|
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingExclusion(null);
|
||||||
|
setFormData({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(["", ""]);
|
||||||
|
setColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||||
|
setEditingExclusion(exclusion);
|
||||||
|
setFormData({
|
||||||
|
exclusionCode: exclusion.exclusionCode,
|
||||||
|
exclusionName: exclusion.exclusionName,
|
||||||
|
fieldNames: exclusion.fieldNames,
|
||||||
|
sourceTable: exclusion.sourceTable,
|
||||||
|
valueColumn: exclusion.valueColumn,
|
||||||
|
labelColumn: exclusion.labelColumn || "",
|
||||||
|
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||||
|
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||||
|
await loadColumns(exclusion.sourceTable);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (exclusionId: number) => {
|
||||||
|
setDeletingExclusionId(exclusionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingExclusionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingExclusionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 추가
|
||||||
|
const addField = () => {
|
||||||
|
setFieldList([...fieldList, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 제거
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
if (fieldList.length <= 2) {
|
||||||
|
toast.error("최소 2개의 필드가 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 값 변경
|
||||||
|
const updateField = (index: number, value: string) => {
|
||||||
|
const newFields = [...fieldList];
|
||||||
|
newFields[index] = value;
|
||||||
|
setFieldList(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필드 목록 합치기
|
||||||
|
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||||
|
if (cleanedFields.length < 2) {
|
||||||
|
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
fieldNames: cleanedFields.join(","),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingExclusion) {
|
||||||
|
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||||
|
} else {
|
||||||
|
response = await mutualExclusionApi.create(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 선택 핸들러
|
||||||
|
const handleTableChange = async (tableName: string) => {
|
||||||
|
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||||
|
await loadColumns(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="배제 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadExclusions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Ban className="h-5 w-5" />
|
||||||
|
상호 배제 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredExclusions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"출발 창고"와 "도착 창고"가 같은 창고를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"현재 부서"와 "이동 부서"가 같은 부서를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>배제 코드</TableHead>
|
||||||
|
<TableHead>배제명</TableHead>
|
||||||
|
<TableHead>대상 필드</TableHead>
|
||||||
|
<TableHead>소스 테이블</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredExclusions.map((exclusion) => (
|
||||||
|
<TableRow key={exclusion.exclusionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{field.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 배제명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.exclusionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||||
|
placeholder="예: 창고 이동 제한"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 필드 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||||
|
<Button variant="outline" size="sm" onClick={addField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fieldList.map((field, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={field}
|
||||||
|
onChange={(e) => updateField(index, e.target.value)}
|
||||||
|
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{fieldList.length > 2 && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 테이블 및 컬럼 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>소스 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.sourceTable
|
||||||
|
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables
|
||||||
|
.filter((t) => t.tableName)
|
||||||
|
.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
sourceTable: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
});
|
||||||
|
await loadColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.valueColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.labelColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.exclusionType}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXCLUSION_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>에러 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.errorMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||||
|
placeholder="동일한 값을 선택할 수 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>상호 배제 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 상호 배제 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default function CascadingRelationsRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/admin/cascading-management");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { ValidationNotification } from "./ValidationNotification";
|
||||||
import { FlowToolbar } from "./FlowToolbar";
|
import { FlowToolbar } from "./FlowToolbar";
|
||||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||||
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
|
|
||||||
import { ConditionNode } from "./nodes/ConditionNode";
|
import { ConditionNode } from "./nodes/ConditionNode";
|
||||||
import { InsertActionNode } from "./nodes/InsertActionNode";
|
import { InsertActionNode } from "./nodes/InsertActionNode";
|
||||||
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
||||||
|
|
@ -26,9 +25,13 @@ import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||||
import { AggregateNode } from "./nodes/AggregateNode";
|
import { AggregateNode } from "./nodes/AggregateNode";
|
||||||
|
import { FormulaTransformNode } from "./nodes/FormulaTransformNode";
|
||||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||||
import { CommentNode } from "./nodes/CommentNode";
|
import { CommentNode } from "./nodes/CommentNode";
|
||||||
import { LogNode } from "./nodes/LogNode";
|
import { LogNode } from "./nodes/LogNode";
|
||||||
|
import { EmailActionNode } from "./nodes/EmailActionNode";
|
||||||
|
import { ScriptActionNode } from "./nodes/ScriptActionNode";
|
||||||
|
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
|
||||||
import { validateFlow } from "@/lib/utils/flowValidation";
|
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||||
|
|
||||||
|
|
@ -38,16 +41,20 @@ const nodeTypes = {
|
||||||
tableSource: TableSourceNode,
|
tableSource: TableSourceNode,
|
||||||
externalDBSource: ExternalDBSourceNode,
|
externalDBSource: ExternalDBSourceNode,
|
||||||
restAPISource: RestAPISourceNode,
|
restAPISource: RestAPISourceNode,
|
||||||
referenceLookup: ReferenceLookupNode,
|
|
||||||
// 변환/조건
|
// 변환/조건
|
||||||
condition: ConditionNode,
|
condition: ConditionNode,
|
||||||
dataTransform: DataTransformNode,
|
dataTransform: DataTransformNode,
|
||||||
aggregate: AggregateNode,
|
aggregate: AggregateNode,
|
||||||
// 액션
|
formulaTransform: FormulaTransformNode,
|
||||||
|
// 데이터 액션
|
||||||
insertAction: InsertActionNode,
|
insertAction: InsertActionNode,
|
||||||
updateAction: UpdateActionNode,
|
updateAction: UpdateActionNode,
|
||||||
deleteAction: DeleteActionNode,
|
deleteAction: DeleteActionNode,
|
||||||
upsertAction: UpsertActionNode,
|
upsertAction: UpsertActionNode,
|
||||||
|
// 외부 연동 액션
|
||||||
|
emailAction: EmailActionNode,
|
||||||
|
scriptAction: ScriptActionNode,
|
||||||
|
httpRequestAction: HttpRequestActionNode,
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
comment: CommentNode,
|
comment: CommentNode,
|
||||||
log: LogNode,
|
log: LogNode,
|
||||||
|
|
@ -248,7 +255,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||||
defaultData.responseMapping = "";
|
defaultData.responseMapping = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 액션 노드의 경우 targetType 기본값 설정
|
// 데이터 액션 노드의 경우 targetType 기본값 설정
|
||||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||||
defaultData.fieldMappings = [];
|
defaultData.fieldMappings = [];
|
||||||
|
|
@ -263,6 +270,49 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 메일 발송 노드
|
||||||
|
if (type === "emailAction") {
|
||||||
|
defaultData.displayName = "메일 발송";
|
||||||
|
defaultData.smtpConfig = {
|
||||||
|
host: "",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
};
|
||||||
|
defaultData.from = "";
|
||||||
|
defaultData.to = "";
|
||||||
|
defaultData.subject = "";
|
||||||
|
defaultData.body = "";
|
||||||
|
defaultData.bodyType = "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 실행 노드
|
||||||
|
if (type === "scriptAction") {
|
||||||
|
defaultData.displayName = "스크립트 실행";
|
||||||
|
defaultData.scriptType = "python";
|
||||||
|
defaultData.executionMode = "inline";
|
||||||
|
defaultData.inlineScript = "";
|
||||||
|
defaultData.inputMethod = "stdin";
|
||||||
|
defaultData.inputFormat = "json";
|
||||||
|
defaultData.outputHandling = {
|
||||||
|
captureStdout: true,
|
||||||
|
captureStderr: true,
|
||||||
|
parseOutput: "text",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 요청 노드
|
||||||
|
if (type === "httpRequestAction") {
|
||||||
|
defaultData.displayName = "HTTP 요청";
|
||||||
|
defaultData.url = "";
|
||||||
|
defaultData.method = "GET";
|
||||||
|
defaultData.bodyType = "none";
|
||||||
|
defaultData.authentication = { type: "none" };
|
||||||
|
defaultData.options = {
|
||||||
|
timeout: 30000,
|
||||||
|
followRedirects: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const newNode: any = {
|
const newNode: any = {
|
||||||
id: `node_${Date.now()}`,
|
id: `node_${Date.now()}`,
|
||||||
type,
|
type,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 변수 태그 에디터 컴포넌트
|
||||||
|
* TipTap 기반으로 본문 내에서 변수를 시각적 태그로 표시하고 삽입할 수 있는 에디터
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import {
|
||||||
|
VariableTagExtension,
|
||||||
|
textToEditorContent,
|
||||||
|
editorContentToText,
|
||||||
|
} from "./VariableTagExtension";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Plus, Variable, X, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// 변수 정보 타입
|
||||||
|
export interface VariableInfo {
|
||||||
|
name: string; // 실제 변수명 (예: customerName)
|
||||||
|
displayName: string; // 표시명 (예: 고객명)
|
||||||
|
type?: string; // 데이터 타입 (예: string, number)
|
||||||
|
description?: string; // 설명
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariableTagEditorProps {
|
||||||
|
value: string; // 현재 값 (예: "안녕하세요, {{customerName}} 님")
|
||||||
|
onChange: (value: string) => void; // 값 변경 콜백
|
||||||
|
variables: VariableInfo[]; // 사용 가능한 변수 목록
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariableTagEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variables,
|
||||||
|
placeholder = "내용을 입력하세요...",
|
||||||
|
className,
|
||||||
|
minHeight = "150px",
|
||||||
|
disabled = false,
|
||||||
|
}: VariableTagEditorProps) {
|
||||||
|
const [isVariablePopoverOpen, setIsVariablePopoverOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// 변수명 → 표시명 맵 생성
|
||||||
|
const variableMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
variables.forEach((v) => {
|
||||||
|
map[v.name] = v.displayName;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [variables]);
|
||||||
|
|
||||||
|
// 필터된 변수 목록
|
||||||
|
const filteredVariables = useMemo(() => {
|
||||||
|
if (!searchQuery) return variables;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return variables.filter(
|
||||||
|
(v) =>
|
||||||
|
v.name.toLowerCase().includes(query) ||
|
||||||
|
v.displayName.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [variables, searchQuery]);
|
||||||
|
|
||||||
|
// TipTap 에디터 초기화
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
// 불필요한 기능 비활성화
|
||||||
|
heading: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
blockquote: false,
|
||||||
|
codeBlock: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
}),
|
||||||
|
VariableTagExtension,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
emptyEditorClass: "is-editor-empty",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: textToEditorContent(value, variableMap),
|
||||||
|
editable: !disabled,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const json = editor.getJSON();
|
||||||
|
const text = editorContentToText(json);
|
||||||
|
onChange(text);
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: cn(
|
||||||
|
"prose prose-sm max-w-none focus:outline-none",
|
||||||
|
"min-h-[100px] p-3",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 value 변경 시 에디터 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && !editor.isFocused) {
|
||||||
|
const currentText = editorContentToText(editor.getJSON());
|
||||||
|
if (currentText !== value) {
|
||||||
|
editor.commands.setContent(textToEditorContent(value, variableMap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, editor, variableMap]);
|
||||||
|
|
||||||
|
// 변수 삽입
|
||||||
|
const insertVariable = useCallback(
|
||||||
|
(variable: VariableInfo) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent({
|
||||||
|
type: "variableTag",
|
||||||
|
attrs: {
|
||||||
|
variableName: variable.name,
|
||||||
|
displayName: variable.displayName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
setIsVariablePopoverOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ 키 입력 시 변수 팝업 표시
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "@" || (event.key === "/" && !event.shiftKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsVariablePopoverOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorElement = editor.view.dom;
|
||||||
|
editorElement.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editorElement.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative rounded-md border", className)}>
|
||||||
|
{/* 툴바 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-gray-50 px-2 py-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Popover
|
||||||
|
open={isVariablePopoverOpen}
|
||||||
|
onOpenChange={setIsVariablePopoverOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Variable className="h-3.5 w-3.5" />
|
||||||
|
변수 삽입
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="변수 검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onValueChange={setSearchQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup heading="사용 가능한 변수">
|
||||||
|
{filteredVariables.map((variable) => (
|
||||||
|
<CommandItem
|
||||||
|
key={variable.name}
|
||||||
|
value={`${variable.name}-${variable.displayName}`}
|
||||||
|
onSelect={() => insertVariable(variable)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{variable.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{variable.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{variable.description && (
|
||||||
|
<span className="mt-0.5 text-xs text-gray-400">
|
||||||
|
{variable.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
<kbd className="rounded bg-gray-200 px-1">@</kbd> 또는{" "}
|
||||||
|
<kbd className="rounded bg-gray-200 px-1">/</kbd> 로 변수 삽입
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에디터 영역 */}
|
||||||
|
<div style={{ minHeight }}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스타일 */}
|
||||||
|
<style jsx global>{`
|
||||||
|
/* 변수 태그 스타일 */
|
||||||
|
.variable-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
margin: 0 1px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variable-tag:hover {
|
||||||
|
background-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선택된 태그 */
|
||||||
|
.variable-tag.ProseMirror-selectednode {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 플레이스홀더 스타일 */
|
||||||
|
.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #9ca3af;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에디터 기본 스타일 */
|
||||||
|
.ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VariableTagEditor;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* TipTap 변수 태그 확장
|
||||||
|
* 본문 내에서 {{변수명}} 형태의 변수를 시각적 태그로 표시
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
// 변수 태그 노드 타입
|
||||||
|
export interface VariableTagOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변수 태그 속성
|
||||||
|
export interface VariableTagAttributes {
|
||||||
|
variableName: string; // 실제 변수명 (예: customerName)
|
||||||
|
displayName: string; // 표시명 (예: 고객명)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 변수 태그 TipTap 확장
|
||||||
|
*/
|
||||||
|
export const VariableTagExtension = Node.create<VariableTagOptions>({
|
||||||
|
name: "variableTag",
|
||||||
|
|
||||||
|
group: "inline",
|
||||||
|
|
||||||
|
inline: true,
|
||||||
|
|
||||||
|
// atom: true로 설정하면 커서가 태그 내부로 들어가지 않음
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
// 선택 가능하도록 설정
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
// 드래그 가능하도록 설정
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
variableName: {
|
||||||
|
default: "",
|
||||||
|
parseHTML: (element) => element.getAttribute("data-variable-name"),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
"data-variable-name": attributes.variableName,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
default: "",
|
||||||
|
parseHTML: (element) => element.getAttribute("data-display-name"),
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
"data-display-name": attributes.displayName,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'span[data-type="variable-tag"]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"span",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
"data-type": "variable-tag",
|
||||||
|
class: "variable-tag",
|
||||||
|
}),
|
||||||
|
HTMLAttributes["data-display-name"] || HTMLAttributes["data-variable-name"],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 키보드 명령어 추가 (Backspace로 삭제)
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Backspace: () =>
|
||||||
|
this.editor.commands.command(({ tr, state }) => {
|
||||||
|
let isVariableTag = false;
|
||||||
|
const { selection } = state;
|
||||||
|
const { empty, anchor } = selection;
|
||||||
|
|
||||||
|
if (!empty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||||
|
if (node.type.name === this.name) {
|
||||||
|
isVariableTag = true;
|
||||||
|
tr.delete(pos, pos + node.nodeSize);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return isVariableTag;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트를 에디터 JSON으로 변환
|
||||||
|
* "안녕하세요, {{customerName}} 님" → TipTap JSON
|
||||||
|
*/
|
||||||
|
export function textToEditorContent(
|
||||||
|
text: string,
|
||||||
|
variableMap: Record<string, string> // { customerName: "고객명" }
|
||||||
|
): any {
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = /\{\{(\w+)\}\}/g;
|
||||||
|
const content: any[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
// 변수 앞의 일반 텍스트
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const beforeText = text.slice(lastIndex, match.index);
|
||||||
|
if (beforeText) {
|
||||||
|
content.push({ type: "text", text: beforeText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변수 태그
|
||||||
|
const variableName = match[1];
|
||||||
|
const displayName = variableMap[variableName] || variableName;
|
||||||
|
content.push({
|
||||||
|
type: "variableTag",
|
||||||
|
attrs: {
|
||||||
|
variableName,
|
||||||
|
displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마지막 텍스트
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
content.push({ type: "text", text: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// content가 비어있으면 빈 paragraph 추가
|
||||||
|
if (content.length === 0) {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에디터 JSON을 텍스트로 변환
|
||||||
|
* TipTap JSON → "안녕하세요, {{customerName}} 님"
|
||||||
|
*/
|
||||||
|
export function editorContentToText(json: any): string {
|
||||||
|
if (!json || !json.content) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
const processNode = (node: any) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
result += node.text || "";
|
||||||
|
} else if (node.type === "variableTag") {
|
||||||
|
result += `{{${node.attrs?.variableName || ""}}}`;
|
||||||
|
} else if (node.type === "paragraph") {
|
||||||
|
if (node.content) {
|
||||||
|
node.content.forEach(processNode);
|
||||||
|
}
|
||||||
|
result += "\n";
|
||||||
|
} else if (node.type === "hardBreak") {
|
||||||
|
result += "\n";
|
||||||
|
} else if (node.content) {
|
||||||
|
node.content.forEach(processNode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
json.content.forEach(processNode);
|
||||||
|
|
||||||
|
// 마지막 줄바꿈 제거
|
||||||
|
return result.replace(/\n$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 발송 액션 노드
|
||||||
|
* 등록된 메일 계정을 선택하여 이메일을 발송하는 노드
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Handle, Position, NodeProps } from "reactflow";
|
||||||
|
import { Mail, User, CheckCircle } from "lucide-react";
|
||||||
|
import type { EmailActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
|
||||||
|
const hasAccount = !!data.accountId;
|
||||||
|
const hasRecipient = data.to && data.to.trim().length > 0;
|
||||||
|
const hasSubject = data.subject && data.subject.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||||
|
selected ? "border-pink-500 shadow-lg" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 입력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-t-lg bg-pink-500 px-3 py-2 text-white">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">{data.displayName || "메일 발송"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{/* 발송 계정 상태 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<User className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{hasAccount ? (
|
||||||
|
<span className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
계정 선택됨
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">발송 계정 선택 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수신자 */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-500">수신자: </span>
|
||||||
|
{hasRecipient ? (
|
||||||
|
<span className="text-gray-700">{data.to}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">미설정</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-500">제목: </span>
|
||||||
|
{hasSubject ? (
|
||||||
|
<span className="truncate text-gray-700">{data.subject}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">미설정</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 형식 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
|
data.bodyType === "html" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.bodyType === "html" ? "HTML" : "TEXT"}
|
||||||
|
</span>
|
||||||
|
{data.attachments && data.attachments.length > 0 && (
|
||||||
|
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
|
||||||
|
첨부 {data.attachments.length}개
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
EmailActionNode.displayName = "EmailActionNode";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수식 변환 노드 (Formula Transform Node)
|
||||||
|
* 산술 연산, 함수, 조건문 등을 사용해 새로운 필드를 계산합니다.
|
||||||
|
* 타겟 테이블의 기존 값을 참조하여 UPSERT 시나리오를 지원합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Handle, Position, NodeProps } from "reactflow";
|
||||||
|
import { Calculator, Database, ArrowRight } from "lucide-react";
|
||||||
|
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||||
|
|
||||||
|
// 수식 타입별 라벨
|
||||||
|
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
|
||||||
|
arithmetic: { label: "산술", color: "bg-orange-500" },
|
||||||
|
function: { label: "함수", color: "bg-blue-500" },
|
||||||
|
condition: { label: "조건", color: "bg-yellow-500" },
|
||||||
|
static: { label: "정적", color: "bg-gray-500" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연산자 표시
|
||||||
|
const OPERATOR_LABELS: Record<string, string> = {
|
||||||
|
"+": "+",
|
||||||
|
"-": "-",
|
||||||
|
"*": "x",
|
||||||
|
"/": "/",
|
||||||
|
"%": "%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수식 요약 생성
|
||||||
|
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||||
|
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||||
|
|
||||||
|
switch (formulaType) {
|
||||||
|
case "arithmetic": {
|
||||||
|
if (!arithmetic) return "미설정";
|
||||||
|
const left = arithmetic.leftOperand;
|
||||||
|
const right = arithmetic.rightOperand;
|
||||||
|
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
|
||||||
|
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
|
||||||
|
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||||
|
}
|
||||||
|
case "function": {
|
||||||
|
if (!func) return "미설정";
|
||||||
|
const args = func.arguments
|
||||||
|
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
|
||||||
|
.join(", ");
|
||||||
|
return `${func.name}(${args})`;
|
||||||
|
}
|
||||||
|
case "condition": {
|
||||||
|
if (!condition) return "미설정";
|
||||||
|
return "CASE WHEN ... THEN ... ELSE ...";
|
||||||
|
}
|
||||||
|
case "static": {
|
||||||
|
return staticValue !== undefined ? String(staticValue) : "미설정";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "미설정";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
|
||||||
|
const transformationCount = data.transformations?.length || 0;
|
||||||
|
const hasTargetLookup = !!data.targetLookup?.tableName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||||
|
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-t-lg bg-orange-500 px-3 py-2 text-white">
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
|
||||||
|
<div className="text-xs opacity-80">
|
||||||
|
{transformationCount}개 변환 {hasTargetLookup && "| 타겟 조회"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="space-y-3 p-3">
|
||||||
|
{/* 타겟 테이블 조회 설정 */}
|
||||||
|
{hasTargetLookup && (
|
||||||
|
<div className="rounded bg-blue-50 p-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1">
|
||||||
|
<Database className="h-3 w-3 text-blue-600" />
|
||||||
|
<span className="text-xs font-medium text-blue-700">타겟 조회</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
|
||||||
|
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center gap-1 rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
|
||||||
|
>
|
||||||
|
{key.sourceFieldLabel || key.sourceField}
|
||||||
|
<ArrowRight className="h-2 w-2" />
|
||||||
|
{key.targetFieldLabel || key.targetField}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{data.targetLookup.lookupKeys.length > 2 && (
|
||||||
|
<span className="text-xs text-blue-500">+{data.targetLookup.lookupKeys.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 변환 규칙들 */}
|
||||||
|
{transformationCount > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.transformations.slice(0, 4).map((trans, idx) => {
|
||||||
|
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
|
||||||
|
return (
|
||||||
|
<div key={trans.id || idx} className="rounded bg-gray-50 p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
|
||||||
|
{typeInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{trans.outputFieldLabel || trans.outputField}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate font-mono text-xs text-gray-500">{getFormulaSummary(trans)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{data.transformations.length > 4 && (
|
||||||
|
<div className="text-center text-xs text-gray-400">... 외 {data.transformations.length - 4}개</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-xs text-gray-400">변환 규칙 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 핸들 */}
|
||||||
|
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-orange-500" />
|
||||||
|
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-orange-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FormulaTransformNode.displayName = "FormulaTransformNode";
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 요청 액션 노드
|
||||||
|
* REST API를 호출하는 노드
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Handle, Position, NodeProps } from "reactflow";
|
||||||
|
import { Globe, Lock, Unlock } from "lucide-react";
|
||||||
|
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
// HTTP 메서드별 색상
|
||||||
|
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
GET: { bg: "bg-green-100", text: "text-green-700" },
|
||||||
|
POST: { bg: "bg-blue-100", text: "text-blue-700" },
|
||||||
|
PUT: { bg: "bg-orange-100", text: "text-orange-700" },
|
||||||
|
PATCH: { bg: "bg-yellow-100", text: "text-yellow-700" },
|
||||||
|
DELETE: { bg: "bg-red-100", text: "text-red-700" },
|
||||||
|
HEAD: { bg: "bg-gray-100", text: "text-gray-700" },
|
||||||
|
OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<HttpRequestActionNodeData>) => {
|
||||||
|
const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET;
|
||||||
|
const hasUrl = data.url && data.url.trim().length > 0;
|
||||||
|
const hasAuth = data.authentication?.type && data.authentication.type !== "none";
|
||||||
|
|
||||||
|
// URL에서 도메인 추출
|
||||||
|
const getDomain = (url: string) => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||||
|
selected ? "border-cyan-500 shadow-lg" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 입력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-t-lg bg-cyan-500 px-3 py-2 text-white">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">{data.displayName || "HTTP 요청"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{/* 메서드 & 인증 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColor.bg} ${methodColor.text}`}>
|
||||||
|
{data.method}
|
||||||
|
</span>
|
||||||
|
{hasAuth ? (
|
||||||
|
<span className="flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{data.authentication?.type}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500">
|
||||||
|
<Unlock className="h-3 w-3" />
|
||||||
|
인증없음
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-500">URL: </span>
|
||||||
|
{hasUrl ? (
|
||||||
|
<span className="truncate text-gray-700" title={data.url}>
|
||||||
|
{getDomain(data.url)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">URL 설정 필요</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 바디 타입 */}
|
||||||
|
{data.bodyType && data.bodyType !== "none" && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-500">Body: </span>
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600">
|
||||||
|
{data.bodyType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 타임아웃 & 재시도 */}
|
||||||
|
<div className="flex gap-2 text-xs text-gray-500">
|
||||||
|
{data.options?.timeout && (
|
||||||
|
<span>타임아웃: {Math.round(data.options.timeout / 1000)}초</span>
|
||||||
|
)}
|
||||||
|
{data.options?.retryCount && data.options.retryCount > 0 && (
|
||||||
|
<span>재시도: {data.options.retryCount}회</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HttpRequestActionNode.displayName = "HttpRequestActionNode";
|
||||||
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 참조 테이블 조회 노드 (내부 DB 전용)
|
|
||||||
* 다른 테이블에서 데이터를 조회하여 조건 비교나 필드 매핑에 사용
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
|
||||||
import { Link2, Database } from "lucide-react";
|
|
||||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
|
||||||
|
|
||||||
export const ReferenceLookupNode = memo(({ data, selected }: NodeProps<ReferenceLookupNodeData>) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
|
||||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
|
||||||
<Link2 className="h-4 w-4" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-semibold">{data.displayName || "참조 조회"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문 */}
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-1 text-xs font-medium text-gray-500">
|
|
||||||
<Database className="h-3 w-3" />
|
|
||||||
내부 DB 참조
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 참조 테이블 */}
|
|
||||||
{data.referenceTable && (
|
|
||||||
<div className="mb-3 rounded bg-purple-50 p-2">
|
|
||||||
<div className="text-xs font-medium text-purple-700">📋 참조 테이블</div>
|
|
||||||
<div className="mt-1 font-mono text-xs text-purple-900">
|
|
||||||
{data.referenceTableLabel || data.referenceTable}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 조인 조건 */}
|
|
||||||
{data.joinConditions && data.joinConditions.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-medium text-gray-700">🔗 조인 조건:</div>
|
|
||||||
<div className="mt-1 space-y-1">
|
|
||||||
{data.joinConditions.map((join, idx) => (
|
|
||||||
<div key={idx} className="text-xs text-gray-600">
|
|
||||||
<span className="font-medium">{join.sourceFieldLabel || join.sourceField}</span>
|
|
||||||
<span className="mx-1 text-purple-500">→</span>
|
|
||||||
<span className="font-medium">{join.referenceFieldLabel || join.referenceField}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* WHERE 조건 */}
|
|
||||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-medium text-gray-700">⚡ WHERE 조건:</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-600">{data.whereConditions.length}개 조건</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 출력 필드 */}
|
|
||||||
{data.outputFields && data.outputFields.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium text-gray-700">📤 출력 필드:</div>
|
|
||||||
<div className="mt-1 max-h-[100px] space-y-1 overflow-y-auto">
|
|
||||||
{data.outputFields.slice(0, 3).map((field, idx) => (
|
|
||||||
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
|
||||||
<span className="font-medium">{field.alias}</span>
|
|
||||||
<span className="text-gray-400">← {field.fieldLabel || field.fieldName}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{data.outputFields.length > 3 && (
|
|
||||||
<div className="text-xs text-gray-400">... 외 {data.outputFields.length - 3}개</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력 핸들 (왼쪽) */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 출력 핸들 (오른쪽) */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ReferenceLookupNode.displayName = "ReferenceLookupNode";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크립트 실행 액션 노드
|
||||||
|
* Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Handle, Position, NodeProps } from "reactflow";
|
||||||
|
import { Terminal, FileCode, Play } from "lucide-react";
|
||||||
|
import type { ScriptActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
// 스크립트 타입별 아이콘 색상
|
||||||
|
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
python: { bg: "bg-yellow-100", text: "text-yellow-700", label: "Python" },
|
||||||
|
shell: { bg: "bg-green-100", text: "text-green-700", label: "Shell" },
|
||||||
|
powershell: { bg: "bg-blue-100", text: "text-blue-700", label: "PowerShell" },
|
||||||
|
node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" },
|
||||||
|
executable: { bg: "bg-gray-100", text: "text-gray-700", label: "실행파일" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScriptActionNode = memo(({ data, selected }: NodeProps<ScriptActionNodeData>) => {
|
||||||
|
const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable;
|
||||||
|
const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||||
|
selected ? "border-emerald-500 shadow-lg" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 입력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">{data.displayName || "스크립트 실행"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{/* 스크립트 타입 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${scriptTypeInfo.bg} ${scriptTypeInfo.text}`}>
|
||||||
|
{scriptTypeInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||||
|
{data.executionMode === "inline" ? "인라인" : "파일"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스크립트 정보 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{data.executionMode === "inline" ? (
|
||||||
|
<>
|
||||||
|
<FileCode className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{hasScript ? (
|
||||||
|
<span className="text-green-600">
|
||||||
|
{data.inlineScript!.split("\n").length}줄 스크립트
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">스크립트 입력 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{hasScript ? (
|
||||||
|
<span className="truncate text-green-600">{data.scriptPath}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">파일 경로 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 방식 */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-gray-500">입력: </span>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{data.inputMethod === "stdin" && "표준입력 (stdin)"}
|
||||||
|
{data.inputMethod === "args" && "명령줄 인자"}
|
||||||
|
{data.inputMethod === "env" && "환경변수"}
|
||||||
|
{data.inputMethod === "file" && "파일"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임아웃 */}
|
||||||
|
{data.options?.timeout && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
타임아웃: {Math.round(data.options.timeout / 1000)}초
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScriptActionNode.displayName = "ScriptActionNode";
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||||
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
|
|
||||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||||
import { ConditionProperties } from "./properties/ConditionProperties";
|
import { ConditionProperties } from "./properties/ConditionProperties";
|
||||||
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
||||||
|
|
@ -17,9 +16,13 @@ import { ExternalDBSourceProperties } from "./properties/ExternalDBSourcePropert
|
||||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||||
import { AggregateProperties } from "./properties/AggregateProperties";
|
import { AggregateProperties } from "./properties/AggregateProperties";
|
||||||
|
import { FormulaTransformProperties } from "./properties/FormulaTransformProperties";
|
||||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||||
import { CommentProperties } from "./properties/CommentProperties";
|
import { CommentProperties } from "./properties/CommentProperties";
|
||||||
import { LogProperties } from "./properties/LogProperties";
|
import { LogProperties } from "./properties/LogProperties";
|
||||||
|
import { EmailActionProperties } from "./properties/EmailActionProperties";
|
||||||
|
import { ScriptActionProperties } from "./properties/ScriptActionProperties";
|
||||||
|
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
|
||||||
import type { NodeType } from "@/types/node-editor";
|
import type { NodeType } from "@/types/node-editor";
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
|
|
@ -31,18 +34,18 @@ export function PropertiesPanel() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
overflow: 'hidden'
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
height: '64px'
|
height: "64px",
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between border-b bg-white p-4"
|
className="flex items-center justify-between border-b bg-white p-4"
|
||||||
>
|
>
|
||||||
|
|
@ -62,8 +65,8 @@ export function PropertiesPanel() {
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
overflowX: 'hidden'
|
overflowX: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedNodes.length === 0 ? (
|
{selectedNodes.length === 0 ? (
|
||||||
|
|
@ -99,9 +102,6 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
case "tableSource":
|
case "tableSource":
|
||||||
return <TableSourceProperties nodeId={node.id} data={node.data} />;
|
return <TableSourceProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
case "referenceLookup":
|
|
||||||
return <ReferenceLookupProperties nodeId={node.id} data={node.data} />;
|
|
||||||
|
|
||||||
case "insertAction":
|
case "insertAction":
|
||||||
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
|
@ -126,6 +126,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
case "aggregate":
|
case "aggregate":
|
||||||
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
return <AggregateProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
case "formulaTransform":
|
||||||
|
return <FormulaTransformProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
case "restAPISource":
|
case "restAPISource":
|
||||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
|
@ -135,6 +138,15 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
case "log":
|
case "log":
|
||||||
return <LogProperties nodeId={node.id} data={node.data} />;
|
return <LogProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
case "emailAction":
|
||||||
|
return <EmailActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
case "scriptAction":
|
||||||
|
return <ScriptActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
case "httpRequestAction":
|
||||||
|
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
@ -161,15 +173,18 @@ function getNodeTypeLabel(type: NodeType): string {
|
||||||
tableSource: "테이블 소스",
|
tableSource: "테이블 소스",
|
||||||
externalDBSource: "외부 DB 소스",
|
externalDBSource: "외부 DB 소스",
|
||||||
restAPISource: "REST API 소스",
|
restAPISource: "REST API 소스",
|
||||||
referenceLookup: "참조 조회",
|
|
||||||
condition: "조건 분기",
|
condition: "조건 분기",
|
||||||
fieldMapping: "필드 매핑",
|
fieldMapping: "필드 매핑",
|
||||||
dataTransform: "데이터 변환",
|
dataTransform: "데이터 변환",
|
||||||
aggregate: "집계",
|
aggregate: "집계",
|
||||||
|
formulaTransform: "수식 변환",
|
||||||
insertAction: "INSERT 액션",
|
insertAction: "INSERT 액션",
|
||||||
updateAction: "UPDATE 액션",
|
updateAction: "UPDATE 액션",
|
||||||
deleteAction: "DELETE 액션",
|
deleteAction: "DELETE 액션",
|
||||||
upsertAction: "UPSERT 액션",
|
upsertAction: "UPSERT 액션",
|
||||||
|
emailAction: "메일 발송",
|
||||||
|
scriptAction: "스크립트 실행",
|
||||||
|
httpRequestAction: "HTTP 요청",
|
||||||
comment: "주석",
|
comment: "주석",
|
||||||
log: "로그",
|
log: "로그",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 발송 노드 속성 편집
|
||||||
|
* - 메일관리에서 등록한 계정을 선택하여 발송
|
||||||
|
* - 변수 태그 에디터로 본문 편집
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, AlertCircle, User } from "lucide-react";
|
||||||
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
|
import { getMailAccounts, type MailAccount } from "@/lib/api/mail";
|
||||||
|
import type { EmailActionNodeData } from "@/types/node-editor";
|
||||||
|
import { VariableTagEditor, type VariableInfo } from "../../editors/VariableTagEditor";
|
||||||
|
|
||||||
|
interface EmailActionPropertiesProps {
|
||||||
|
nodeId: string;
|
||||||
|
data: EmailActionNodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) {
|
||||||
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
|
// 메일 계정 목록
|
||||||
|
const [mailAccounts, setMailAccounts] = useState<MailAccount[]>([]);
|
||||||
|
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||||
|
const [accountError, setAccountError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 🆕 플로우에서 사용 가능한 변수 목록 계산
|
||||||
|
const availableVariables = useMemo<VariableInfo[]>(() => {
|
||||||
|
const variables: VariableInfo[] = [];
|
||||||
|
|
||||||
|
// 기본 시스템 변수
|
||||||
|
variables.push(
|
||||||
|
{ name: "timestamp", displayName: "현재 시간", description: "메일 발송 시점의 타임스탬프" },
|
||||||
|
{ name: "sourceData", displayName: "소스 데이터", description: "전체 소스 데이터 (JSON)" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 현재 노드에 연결된 소스 노드들에서 필드 정보 수집
|
||||||
|
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||||
|
|
||||||
|
for (const edge of incomingEdges) {
|
||||||
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||||
|
if (!sourceNode) continue;
|
||||||
|
|
||||||
|
const nodeData = sourceNode.data as any;
|
||||||
|
|
||||||
|
// 테이블 소스 노드인 경우
|
||||||
|
if (sourceNode.type === "tableSource" && nodeData.fields) {
|
||||||
|
const tableName = nodeData.tableName || "테이블";
|
||||||
|
nodeData.fields.forEach((field: any) => {
|
||||||
|
variables.push({
|
||||||
|
name: field.name,
|
||||||
|
displayName: field.displayName || field.label || field.name,
|
||||||
|
type: field.type,
|
||||||
|
description: `${tableName} 테이블의 필드`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 DB 소스 노드인 경우
|
||||||
|
if (sourceNode.type === "externalDBSource" && nodeData.fields) {
|
||||||
|
const tableName = nodeData.tableName || "외부 테이블";
|
||||||
|
nodeData.fields.forEach((field: any) => {
|
||||||
|
variables.push({
|
||||||
|
name: field.name,
|
||||||
|
displayName: field.displayName || field.label || field.name,
|
||||||
|
type: field.type,
|
||||||
|
description: `${tableName} (외부 DB) 필드`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API 소스 노드인 경우
|
||||||
|
if (sourceNode.type === "restAPISource" && nodeData.responseFields) {
|
||||||
|
nodeData.responseFields.forEach((field: any) => {
|
||||||
|
variables.push({
|
||||||
|
name: field.name,
|
||||||
|
displayName: field.displayName || field.label || field.name,
|
||||||
|
type: field.type,
|
||||||
|
description: "REST API 응답 필드",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 변환 노드인 경우 - 출력 필드 추가
|
||||||
|
if (sourceNode.type === "dataTransform" && nodeData.transformations) {
|
||||||
|
nodeData.transformations.forEach((transform: any) => {
|
||||||
|
if (transform.targetField) {
|
||||||
|
variables.push({
|
||||||
|
name: transform.targetField,
|
||||||
|
displayName: transform.targetField,
|
||||||
|
description: "데이터 변환 결과 필드",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거
|
||||||
|
const uniqueVariables = variables.filter(
|
||||||
|
(v, index, self) => index === self.findIndex((t) => t.name === v.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
return uniqueVariables;
|
||||||
|
}, [nodes, edges, nodeId]);
|
||||||
|
|
||||||
|
// 로컬 상태
|
||||||
|
const [displayName, setDisplayName] = useState(data.displayName || "메일 발송");
|
||||||
|
|
||||||
|
// 계정 선택
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState(data.accountId || "");
|
||||||
|
|
||||||
|
// 🆕 수신자 컴포넌트 사용 여부
|
||||||
|
const [useRecipientComponent, setUseRecipientComponent] = useState(data.useRecipientComponent ?? false);
|
||||||
|
const [recipientToField, setRecipientToField] = useState(data.recipientToField || "mailTo");
|
||||||
|
const [recipientCcField, setRecipientCcField] = useState(data.recipientCcField || "mailCc");
|
||||||
|
|
||||||
|
// 메일 내용
|
||||||
|
const [to, setTo] = useState(data.to || "");
|
||||||
|
const [cc, setCc] = useState(data.cc || "");
|
||||||
|
const [bcc, setBcc] = useState(data.bcc || "");
|
||||||
|
const [subject, setSubject] = useState(data.subject || "");
|
||||||
|
const [body, setBody] = useState(data.body || "");
|
||||||
|
const [bodyType, setBodyType] = useState<"text" | "html">(data.bodyType || "text");
|
||||||
|
|
||||||
|
// 고급 설정
|
||||||
|
const [replyTo, setReplyTo] = useState(data.replyTo || "");
|
||||||
|
const [priority, setPriority] = useState<"high" | "normal" | "low">(data.priority || "normal");
|
||||||
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
|
||||||
|
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "3");
|
||||||
|
|
||||||
|
// 메일 계정 목록 로드
|
||||||
|
const loadMailAccounts = useCallback(async () => {
|
||||||
|
setIsLoadingAccounts(true);
|
||||||
|
setAccountError(null);
|
||||||
|
try {
|
||||||
|
const accounts = await getMailAccounts();
|
||||||
|
setMailAccounts(accounts.filter(acc => acc.status === 'active'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("메일 계정 로드 실패:", error);
|
||||||
|
setAccountError("메일 계정을 불러오는데 실패했습니다");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAccounts(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 메일 계정 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadMailAccounts();
|
||||||
|
}, [loadMailAccounts]);
|
||||||
|
|
||||||
|
// 데이터 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(data.displayName || "메일 발송");
|
||||||
|
setSelectedAccountId(data.accountId || "");
|
||||||
|
setUseRecipientComponent(data.useRecipientComponent ?? false);
|
||||||
|
setRecipientToField(data.recipientToField || "mailTo");
|
||||||
|
setRecipientCcField(data.recipientCcField || "mailCc");
|
||||||
|
setTo(data.to || "");
|
||||||
|
setCc(data.cc || "");
|
||||||
|
setBcc(data.bcc || "");
|
||||||
|
setSubject(data.subject || "");
|
||||||
|
setBody(data.body || "");
|
||||||
|
setBodyType(data.bodyType || "text");
|
||||||
|
setReplyTo(data.replyTo || "");
|
||||||
|
setPriority(data.priority || "normal");
|
||||||
|
setTimeout(data.options?.timeout?.toString() || "30000");
|
||||||
|
setRetryCount(data.options?.retryCount?.toString() || "3");
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 선택된 계정 정보
|
||||||
|
const selectedAccount = mailAccounts.find(acc => acc.id === selectedAccountId);
|
||||||
|
|
||||||
|
// 노드 업데이트 함수
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<EmailActionNodeData>) => {
|
||||||
|
updateNode(nodeId, {
|
||||||
|
...data,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeId, data, updateNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 표시명 변경
|
||||||
|
const handleDisplayNameChange = (value: string) => {
|
||||||
|
setDisplayName(value);
|
||||||
|
updateNodeData({ displayName: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계정 선택 변경
|
||||||
|
const handleAccountChange = (accountId: string) => {
|
||||||
|
setSelectedAccountId(accountId);
|
||||||
|
const account = mailAccounts.find(acc => acc.id === accountId);
|
||||||
|
updateNodeData({
|
||||||
|
accountId,
|
||||||
|
// 계정의 이메일을 발신자로 자동 설정
|
||||||
|
from: account?.email || ""
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메일 내용 업데이트
|
||||||
|
const updateMailContent = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
to,
|
||||||
|
cc: cc || undefined,
|
||||||
|
bcc: bcc || undefined,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
bodyType,
|
||||||
|
replyTo: replyTo || undefined,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
}, [to, cc, bcc, subject, body, bodyType, replyTo, priority, updateNodeData]);
|
||||||
|
|
||||||
|
// 옵션 업데이트
|
||||||
|
const updateOptions = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
options: {
|
||||||
|
timeout: parseInt(timeout) || 30000,
|
||||||
|
retryCount: parseInt(retryCount) || 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [timeout, retryCount, updateNodeData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시명</Label>
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||||
|
placeholder="메일 발송"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="account" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="account" className="text-xs">
|
||||||
|
<User className="mr-1 h-3 w-3" />
|
||||||
|
계정
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mail" className="text-xs">
|
||||||
|
<Mail className="mr-1 h-3 w-3" />
|
||||||
|
메일
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="content" className="text-xs">
|
||||||
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
|
본문
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="options" className="text-xs">
|
||||||
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
|
옵션
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 계정 선택 탭 */}
|
||||||
|
<TabsContent value="account" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">발송 계정 선택 *</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMailAccounts}
|
||||||
|
disabled={isLoadingAccounts}
|
||||||
|
className="h-6 px-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${isLoadingAccounts ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accountError && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{accountError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedAccountId}
|
||||||
|
onValueChange={handleAccountChange}
|
||||||
|
disabled={isLoadingAccounts}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder={isLoadingAccounts ? "로딩 중..." : "메일 계정을 선택하세요"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{mailAccounts.length === 0 ? (
|
||||||
|
<div className="p-2 text-xs text-gray-500">
|
||||||
|
등록된 메일 계정이 없습니다.
|
||||||
|
<br />
|
||||||
|
관리자 > 메일관리 > 계정관리에서 추가하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
mailAccounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={account.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{account.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">({account.email})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 계정 정보 표시 */}
|
||||||
|
{selectedAccount && (
|
||||||
|
<Card className="bg-green-50 border-green-200">
|
||||||
|
<CardContent className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-green-700">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">선택된 계정</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs space-y-1 text-green-800">
|
||||||
|
<div><strong>이름:</strong> {selectedAccount.name}</div>
|
||||||
|
<div><strong>이메일:</strong> {selectedAccount.email}</div>
|
||||||
|
<div><strong>SMTP:</strong> {selectedAccount.smtpHost}:{selectedAccount.smtpPort}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedAccount && mailAccounts.length > 0 && (
|
||||||
|
<Card className="bg-yellow-50 border-yellow-200">
|
||||||
|
<CardContent className="p-3 text-xs text-yellow-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>메일 발송을 위해 계정을 선택해주세요.</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mailAccounts.length === 0 && !isLoadingAccounts && (
|
||||||
|
<Card className="bg-gray-50">
|
||||||
|
<CardContent className="p-3 text-xs text-gray-600">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">메일 계정 등록 방법:</div>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>관리자 메뉴로 이동</li>
|
||||||
|
<li>메일관리 > 계정관리 선택</li>
|
||||||
|
<li>새 계정 추가 버튼 클릭</li>
|
||||||
|
<li>SMTP 정보 입력 후 저장</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 메일 설정 탭 */}
|
||||||
|
<TabsContent value="mail" className="space-y-3 pt-3">
|
||||||
|
{/* 발신자는 선택된 계정에서 자동으로 설정됨 */}
|
||||||
|
{selectedAccount && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">발신자 (From)</Label>
|
||||||
|
<div className="h-8 px-3 py-2 text-sm bg-gray-100 rounded-md border flex items-center">
|
||||||
|
{selectedAccount.email}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">선택한 계정의 이메일 주소가 자동으로 사용됩니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 수신자 컴포넌트 사용 옵션 */}
|
||||||
|
<Card className={useRecipientComponent ? "bg-blue-50 border-blue-200" : "bg-gray-50"}>
|
||||||
|
<CardContent className="p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">수신자 컴포넌트 사용</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
화면에 배치한 "메일 수신자 선택" 컴포넌트의 값을 자동으로 사용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={useRecipientComponent}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setUseRecipientComponent(checked);
|
||||||
|
if (checked) {
|
||||||
|
// 체크 시 자동으로 변수 설정
|
||||||
|
updateNodeData({
|
||||||
|
useRecipientComponent: true,
|
||||||
|
recipientToField,
|
||||||
|
recipientCcField,
|
||||||
|
to: `{{${recipientToField}}}`,
|
||||||
|
cc: `{{${recipientCcField}}}`,
|
||||||
|
});
|
||||||
|
setTo(`{{${recipientToField}}}`);
|
||||||
|
setCc(`{{${recipientCcField}}}`);
|
||||||
|
} else {
|
||||||
|
updateNodeData({
|
||||||
|
useRecipientComponent: false,
|
||||||
|
to: "",
|
||||||
|
cc: "",
|
||||||
|
});
|
||||||
|
setTo("");
|
||||||
|
setCc("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드명 설정 (수신자 컴포넌트 사용 시) */}
|
||||||
|
{useRecipientComponent && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-blue-200">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">수신자 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={recipientToField}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newField = e.target.value;
|
||||||
|
setRecipientToField(newField);
|
||||||
|
setTo(`{{${newField}}}`);
|
||||||
|
updateNodeData({
|
||||||
|
recipientToField: newField,
|
||||||
|
to: `{{${newField}}}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="mailTo"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">참조 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={recipientCcField}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newField = e.target.value;
|
||||||
|
setRecipientCcField(newField);
|
||||||
|
setCc(`{{${newField}}}`);
|
||||||
|
updateNodeData({
|
||||||
|
recipientCcField: newField,
|
||||||
|
cc: `{{${newField}}}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="mailCc"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600 bg-blue-100 p-2 rounded">
|
||||||
|
<strong>자동 설정됨:</strong>
|
||||||
|
<br />
|
||||||
|
수신자: <code className="bg-white px-1 rounded">{`{{${recipientToField}}}`}</code>
|
||||||
|
<br />
|
||||||
|
참조: <code className="bg-white px-1 rounded">{`{{${recipientCcField}}}`}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 수신자 직접 입력 (컴포넌트 미사용 시) */}
|
||||||
|
{!useRecipientComponent && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">수신자 (To) *</Label>
|
||||||
|
<Input
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="recipient@example.com (쉼표로 구분, {{변수}} 사용 가능)"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">참조 (CC)</Label>
|
||||||
|
<Input
|
||||||
|
value={cc}
|
||||||
|
onChange={(e) => setCc(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="cc@example.com"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">숨은 참조 (BCC)</Label>
|
||||||
|
<Input
|
||||||
|
value={bcc}
|
||||||
|
onChange={(e) => setBcc(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="bcc@example.com"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">회신 주소 (Reply-To)</Label>
|
||||||
|
<Input
|
||||||
|
value={replyTo}
|
||||||
|
onChange={(e) => setReplyTo(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="reply@example.com"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">우선순위</Label>
|
||||||
|
<Select value={priority} onValueChange={(v: "high" | "normal" | "low") => {
|
||||||
|
setPriority(v);
|
||||||
|
updateNodeData({ priority: v });
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="high">높음</SelectItem>
|
||||||
|
<SelectItem value="normal">보통</SelectItem>
|
||||||
|
<SelectItem value="low">낮음</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 본문 탭 */}
|
||||||
|
<TabsContent value="content" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">제목 *</Label>
|
||||||
|
<Input
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="메일 제목 ({{변수}} 사용 가능)"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">본문 형식</Label>
|
||||||
|
<Select value={bodyType} onValueChange={(v: "text" | "html") => {
|
||||||
|
setBodyType(v);
|
||||||
|
updateNodeData({ bodyType: v });
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트 (변수 태그 에디터)</SelectItem>
|
||||||
|
<SelectItem value="html">HTML (직접 입력)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">본문 내용</Label>
|
||||||
|
|
||||||
|
{/* 텍스트 형식: 변수 태그 에디터 사용 */}
|
||||||
|
{bodyType === "text" && (
|
||||||
|
<VariableTagEditor
|
||||||
|
value={body}
|
||||||
|
onChange={(newBody) => {
|
||||||
|
setBody(newBody);
|
||||||
|
updateNodeData({ body: newBody });
|
||||||
|
}}
|
||||||
|
variables={availableVariables}
|
||||||
|
placeholder="메일 본문을 입력하세요. @ 또는 / 키로 변수를 삽입할 수 있습니다."
|
||||||
|
minHeight="200px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTML 형식: 직접 입력 */}
|
||||||
|
{bodyType === "html" && (
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onBlur={updateMailContent}
|
||||||
|
placeholder="<html><body>...</body></html>"
|
||||||
|
className="min-h-[200px] text-sm font-mono"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 변수 안내 (HTML 모드에서만 표시) */}
|
||||||
|
{bodyType === "html" && (
|
||||||
|
<Card className="bg-gray-50">
|
||||||
|
<CardContent className="p-3 text-xs text-gray-600">
|
||||||
|
<div className="font-medium mb-1">사용 가능한 템플릿 변수:</div>
|
||||||
|
{availableVariables.slice(0, 5).map((v) => (
|
||||||
|
<code key={v.name} className="block">
|
||||||
|
{`{{${v.name}}}`} - {v.displayName}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
{availableVariables.length > 5 && (
|
||||||
|
<span className="text-gray-400">...외 {availableVariables.length - 5}개</span>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 변수 태그 에디터 안내 (텍스트 모드에서만 표시) */}
|
||||||
|
{bodyType === "text" && (
|
||||||
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
|
<CardContent className="p-3 text-xs text-blue-700">
|
||||||
|
<div className="font-medium mb-1">변수 삽입 방법:</div>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
|
<li><kbd className="bg-blue-100 px-1 rounded">@</kbd> 또는 <kbd className="bg-blue-100 px-1 rounded">/</kbd> 키를 눌러 변수 목록 열기</li>
|
||||||
|
<li>툴바의 "변수 삽입" 버튼 클릭</li>
|
||||||
|
<li>삽입된 변수는 파란색 태그로 표시됩니다</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 옵션 탭 */}
|
||||||
|
<TabsContent value="options" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={timeout}
|
||||||
|
onChange={(e) => setTimeout(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="30000"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">재시도 횟수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={retryCount}
|
||||||
|
onChange={(e) => setRetryCount(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="3"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,969 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수식 변환 노드 속성 편집 패널
|
||||||
|
* - 타겟 테이블 조회 설정 (기존 값 참조용)
|
||||||
|
* - 산술 연산, 함수, 조건, 정적 값 변환 규칙 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Plus, Trash2, Calculator, Database, ArrowRight, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
|
||||||
|
|
||||||
|
interface FormulaTransformPropertiesProps {
|
||||||
|
nodeId: string;
|
||||||
|
data: FormulaTransformNodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableOption {
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수식 타입 옵션
|
||||||
|
const FORMULA_TYPES: Array<{ value: FormulaType; label: string; description: string }> = [
|
||||||
|
{ value: "arithmetic", label: "산술 연산", description: "덧셈, 뺄셈, 곱셈, 나눗셈" },
|
||||||
|
{ value: "function", label: "함수", description: "NOW, COALESCE, CONCAT 등" },
|
||||||
|
{ value: "condition", label: "조건", description: "CASE WHEN ... THEN ... ELSE" },
|
||||||
|
{ value: "static", label: "정적 값", description: "고정 값 설정" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 산술 연산자
|
||||||
|
const ARITHMETIC_OPERATORS = [
|
||||||
|
{ value: "+", label: "더하기 (+)" },
|
||||||
|
{ value: "-", label: "빼기 (-)" },
|
||||||
|
{ value: "*", label: "곱하기 (*)" },
|
||||||
|
{ value: "/", label: "나누기 (/)" },
|
||||||
|
{ value: "%", label: "나머지 (%)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 함수 목록
|
||||||
|
const FUNCTIONS = [
|
||||||
|
{ value: "NOW", label: "NOW()", description: "현재 시간", argCount: 0 },
|
||||||
|
{ value: "COALESCE", label: "COALESCE(a, b)", description: "NULL이면 대체값 사용", argCount: 2 },
|
||||||
|
{ value: "CONCAT", label: "CONCAT(a, b, ...)", description: "문자열 연결", argCount: -1 },
|
||||||
|
{ value: "UPPER", label: "UPPER(text)", description: "대문자 변환", argCount: 1 },
|
||||||
|
{ value: "LOWER", label: "LOWER(text)", description: "소문자 변환", argCount: 1 },
|
||||||
|
{ value: "TRIM", label: "TRIM(text)", description: "공백 제거", argCount: 1 },
|
||||||
|
{ value: "ROUND", label: "ROUND(number)", description: "반올림", argCount: 1 },
|
||||||
|
{ value: "ABS", label: "ABS(number)", description: "절대값", argCount: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 조건 연산자
|
||||||
|
const CONDITION_OPERATORS = [
|
||||||
|
{ value: "=", label: "같음 (=)" },
|
||||||
|
{ value: "!=", label: "다름 (!=)" },
|
||||||
|
{ value: ">", label: "보다 큼 (>)" },
|
||||||
|
{ value: "<", label: "보다 작음 (<)" },
|
||||||
|
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||||
|
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||||
|
{ value: "IS_NULL", label: "NULL임" },
|
||||||
|
{ value: "IS_NOT_NULL", label: "NULL 아님" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPropertiesProps) {
|
||||||
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
|
// 로컬 상태
|
||||||
|
const [displayName, setDisplayName] = useState(data.displayName || "수식 변환");
|
||||||
|
const [targetLookup, setTargetLookup] = useState(data.targetLookup);
|
||||||
|
const [transformations, setTransformations] = useState(data.transformations || []);
|
||||||
|
|
||||||
|
// 테이블/컬럼 관련 상태
|
||||||
|
const [tables, setTables] = useState<TableOption[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
const [tablesOpen, setTablesOpen] = useState(false);
|
||||||
|
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||||
|
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||||
|
|
||||||
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(data.displayName || "수식 변환");
|
||||||
|
setTargetLookup(data.targetLookup);
|
||||||
|
setTransformations(data.transformations || []);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 테이블 목록 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 타겟 테이블 변경 시 컬럼 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetLookup?.tableName) {
|
||||||
|
loadTargetColumns(targetLookup.tableName);
|
||||||
|
}
|
||||||
|
}, [targetLookup?.tableName]);
|
||||||
|
|
||||||
|
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로)
|
||||||
|
useEffect(() => {
|
||||||
|
const getAllSourceFields = (
|
||||||
|
targetNodeId: string,
|
||||||
|
visitedNodes = new Set<string>(),
|
||||||
|
): Array<{ name: string; label?: string }> => {
|
||||||
|
if (visitedNodes.has(targetNodeId)) return [];
|
||||||
|
visitedNodes.add(targetNodeId);
|
||||||
|
|
||||||
|
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||||||
|
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||||
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||||
|
|
||||||
|
const fields: Array<{ name: string; label?: string }> = [];
|
||||||
|
|
||||||
|
sourceNodes.forEach((node) => {
|
||||||
|
// 테이블/외부DB 소스 노드
|
||||||
|
if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||||
|
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||||
|
if (nodeFields && Array.isArray(nodeFields)) {
|
||||||
|
nodeFields.forEach((field: any) => {
|
||||||
|
const fieldName = field.name || field.fieldName || field.column_name;
|
||||||
|
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||||
|
if (fieldName) {
|
||||||
|
fields.push({ name: fieldName, label: fieldLabel });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 데이터 변환 노드
|
||||||
|
else if (node.type === "dataTransform") {
|
||||||
|
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperFields);
|
||||||
|
|
||||||
|
// 변환된 필드 추가
|
||||||
|
if ((node.data as any).transformations) {
|
||||||
|
(node.data as any).transformations.forEach((transform: any) => {
|
||||||
|
const targetField = transform.targetField || transform.sourceField;
|
||||||
|
if (targetField) {
|
||||||
|
fields.push({
|
||||||
|
name: targetField,
|
||||||
|
label: transform.targetFieldLabel || targetField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 집계 노드
|
||||||
|
else if (node.type === "aggregate") {
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
// 그룹 기준 필드
|
||||||
|
if (nodeData.groupByFields) {
|
||||||
|
nodeData.groupByFields.forEach((groupField: any) => {
|
||||||
|
const fieldName = groupField.field || groupField.fieldName;
|
||||||
|
if (fieldName) {
|
||||||
|
fields.push({ name: fieldName, label: groupField.fieldLabel || fieldName });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 집계 결과 필드
|
||||||
|
const aggregations = nodeData.aggregations || [];
|
||||||
|
aggregations.forEach((aggFunc: any) => {
|
||||||
|
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
|
||||||
|
if (outputFieldName) {
|
||||||
|
fields.push({ name: outputFieldName, label: aggFunc.outputFieldLabel || outputFieldName });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 기타 노드: 상위 탐색
|
||||||
|
else {
|
||||||
|
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperFields);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = getAllSourceFields(nodeId);
|
||||||
|
const uniqueFields = Array.from(new Map(fields.map((field) => [field.name, field])).values());
|
||||||
|
setSourceFields(uniqueFields);
|
||||||
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
|
// 저장 함수
|
||||||
|
const saveToNode = useCallback(
|
||||||
|
(updates: Partial<FormulaTransformNodeData>) => {
|
||||||
|
updateNode(nodeId, {
|
||||||
|
displayName,
|
||||||
|
targetLookup,
|
||||||
|
transformations,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeId, updateNode, displayName, targetLookup, transformations],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테이블 목록 로딩
|
||||||
|
const loadTables = async () => {
|
||||||
|
try {
|
||||||
|
setTablesLoading(true);
|
||||||
|
const tableList = await tableTypeApi.getTables();
|
||||||
|
const options: TableOption[] = tableList.map((table) => ({
|
||||||
|
tableName: table.tableName,
|
||||||
|
displayName: table.displayName || table.tableName,
|
||||||
|
label: (table as any).tableLabel || table.displayName || table.tableName,
|
||||||
|
}));
|
||||||
|
setTables(options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로딩 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setTablesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 테이블 컬럼 로딩
|
||||||
|
const loadTargetColumns = async (tableName: string) => {
|
||||||
|
try {
|
||||||
|
setTargetColumnsLoading(true);
|
||||||
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
columnLabel: col.label_ko || col.columnLabel,
|
||||||
|
dataType: col.data_type || col.dataType || "unknown",
|
||||||
|
}));
|
||||||
|
setTargetColumns(columnInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로딩 실패:", error);
|
||||||
|
setTargetColumns([]);
|
||||||
|
} finally {
|
||||||
|
setTargetColumnsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 테이블 선택
|
||||||
|
const handleTargetTableSelect = async (tableName: string) => {
|
||||||
|
const selectedTable = tables.find((t) => t.tableName === tableName);
|
||||||
|
const newTargetLookup = {
|
||||||
|
tableName,
|
||||||
|
tableLabel: selectedTable?.label,
|
||||||
|
lookupKeys: targetLookup?.lookupKeys || [],
|
||||||
|
};
|
||||||
|
setTargetLookup(newTargetLookup);
|
||||||
|
saveToNode({ targetLookup: newTargetLookup });
|
||||||
|
setTablesOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 테이블 조회 키 추가
|
||||||
|
const handleAddLookupKey = () => {
|
||||||
|
const newLookupKeys = [...(targetLookup?.lookupKeys || []), { sourceField: "", targetField: "" }];
|
||||||
|
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||||
|
setTargetLookup(newTargetLookup);
|
||||||
|
saveToNode({ targetLookup: newTargetLookup });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 테이블 조회 키 삭제
|
||||||
|
const handleRemoveLookupKey = (index: number) => {
|
||||||
|
const newLookupKeys = (targetLookup?.lookupKeys || []).filter((_, i) => i !== index);
|
||||||
|
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||||
|
setTargetLookup(newTargetLookup);
|
||||||
|
saveToNode({ targetLookup: newTargetLookup });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 테이블 조회 키 변경
|
||||||
|
const handleLookupKeyChange = (index: number, field: string, value: string) => {
|
||||||
|
const newLookupKeys = [...(targetLookup?.lookupKeys || [])];
|
||||||
|
if (field === "sourceField") {
|
||||||
|
const sourceField = sourceFields.find((f) => f.name === value);
|
||||||
|
newLookupKeys[index] = {
|
||||||
|
...newLookupKeys[index],
|
||||||
|
sourceField: value,
|
||||||
|
sourceFieldLabel: sourceField?.label,
|
||||||
|
};
|
||||||
|
} else if (field === "targetField") {
|
||||||
|
const targetCol = targetColumns.find((c) => c.columnName === value);
|
||||||
|
newLookupKeys[index] = {
|
||||||
|
...newLookupKeys[index],
|
||||||
|
targetField: value,
|
||||||
|
targetFieldLabel: targetCol?.columnLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
|
||||||
|
setTargetLookup(newTargetLookup);
|
||||||
|
saveToNode({ targetLookup: newTargetLookup });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 변환 규칙 추가
|
||||||
|
const handleAddTransformation = () => {
|
||||||
|
const newTransformation = {
|
||||||
|
id: `trans_${Date.now()}`,
|
||||||
|
outputField: "",
|
||||||
|
outputFieldLabel: "",
|
||||||
|
formulaType: "arithmetic" as FormulaType,
|
||||||
|
arithmetic: {
|
||||||
|
leftOperand: { type: "source" as const, field: "" },
|
||||||
|
operator: "+" as const,
|
||||||
|
rightOperand: { type: "source" as const, field: "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const newTransformations = [...transformations, newTransformation];
|
||||||
|
setTransformations(newTransformations);
|
||||||
|
saveToNode({ transformations: newTransformations });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 변환 규칙 삭제
|
||||||
|
const handleRemoveTransformation = (index: number) => {
|
||||||
|
const newTransformations = transformations.filter((_, i) => i !== index);
|
||||||
|
setTransformations(newTransformations);
|
||||||
|
saveToNode({ transformations: newTransformations });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 변환 규칙 변경
|
||||||
|
const handleTransformationChange = (
|
||||||
|
index: number,
|
||||||
|
updates: Partial<FormulaTransformNodeData["transformations"][0]>,
|
||||||
|
) => {
|
||||||
|
const newTransformations = [...transformations];
|
||||||
|
newTransformations[index] = { ...newTransformations[index], ...updates };
|
||||||
|
setTransformations(newTransformations);
|
||||||
|
saveToNode({ transformations: newTransformations });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수식 타입 변경
|
||||||
|
const handleFormulaTypeChange = (index: number, newType: FormulaType) => {
|
||||||
|
const newTransformations = [...transformations];
|
||||||
|
const trans = newTransformations[index];
|
||||||
|
|
||||||
|
// 기본값 설정
|
||||||
|
switch (newType) {
|
||||||
|
case "arithmetic":
|
||||||
|
trans.arithmetic = {
|
||||||
|
leftOperand: { type: "source", field: "" },
|
||||||
|
operator: "+",
|
||||||
|
rightOperand: { type: "source", field: "" },
|
||||||
|
};
|
||||||
|
trans.function = undefined;
|
||||||
|
trans.condition = undefined;
|
||||||
|
trans.staticValue = undefined;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
trans.function = {
|
||||||
|
name: "COALESCE",
|
||||||
|
arguments: [
|
||||||
|
{ type: "source", field: "" },
|
||||||
|
{ type: "static", value: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
trans.arithmetic = undefined;
|
||||||
|
trans.condition = undefined;
|
||||||
|
trans.staticValue = undefined;
|
||||||
|
break;
|
||||||
|
case "condition":
|
||||||
|
trans.condition = {
|
||||||
|
when: {
|
||||||
|
leftOperand: { type: "source", field: "" },
|
||||||
|
operator: "=",
|
||||||
|
rightOperand: { type: "static", value: "" },
|
||||||
|
},
|
||||||
|
then: { type: "static", value: "" },
|
||||||
|
else: { type: "static", value: "" },
|
||||||
|
};
|
||||||
|
trans.arithmetic = undefined;
|
||||||
|
trans.function = undefined;
|
||||||
|
trans.staticValue = undefined;
|
||||||
|
break;
|
||||||
|
case "static":
|
||||||
|
trans.staticValue = "";
|
||||||
|
trans.arithmetic = undefined;
|
||||||
|
trans.function = undefined;
|
||||||
|
trans.condition = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
trans.formulaType = newType;
|
||||||
|
setTransformations(newTransformations);
|
||||||
|
saveToNode({ transformations: newTransformations });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이전 변환 결과 필드 목록 (result 타입용)
|
||||||
|
const getResultFields = (currentIndex: number) => {
|
||||||
|
return transformations
|
||||||
|
.slice(0, currentIndex)
|
||||||
|
.filter((t) => t.outputField)
|
||||||
|
.map((t) => ({
|
||||||
|
name: t.outputField,
|
||||||
|
label: t.outputFieldLabel || t.outputField,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 피연산자 렌더링 (산술, 함수, 조건에서 공통 사용)
|
||||||
|
const renderOperandSelector = (
|
||||||
|
operand: { type: string; field?: string; fieldLabel?: string; value?: string | number; resultField?: string },
|
||||||
|
onChange: (updates: any) => void,
|
||||||
|
currentTransIndex: number,
|
||||||
|
) => {
|
||||||
|
const resultFields = getResultFields(currentTransIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={operand.type}
|
||||||
|
onValueChange={(value) => onChange({ type: value, field: "", value: undefined, resultField: "" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="source">소스 필드 (source.*)</SelectItem>
|
||||||
|
{targetLookup?.tableName && <SelectItem value="target">타겟 필드 (target.*)</SelectItem>}
|
||||||
|
<SelectItem value="static">정적 값</SelectItem>
|
||||||
|
{resultFields.length > 0 && <SelectItem value="result">이전 결과 (result.*)</SelectItem>}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{operand.type === "source" && (
|
||||||
|
<Select
|
||||||
|
value={operand.field || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const sf = sourceFields.find((f) => f.name === value);
|
||||||
|
onChange({ ...operand, field: value, fieldLabel: sf?.label });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceFields.length === 0 ? (
|
||||||
|
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 없음</div>
|
||||||
|
) : (
|
||||||
|
sourceFields.map((f) => (
|
||||||
|
<SelectItem key={f.name} value={f.name}>
|
||||||
|
{f.label || f.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operand.type === "target" && (
|
||||||
|
<Select
|
||||||
|
value={operand.field || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const tc = targetColumns.find((c) => c.columnName === value);
|
||||||
|
onChange({ ...operand, field: value, fieldLabel: tc?.columnLabel });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="타겟 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetColumns.length === 0 ? (
|
||||||
|
<div className="p-2 text-center text-xs text-gray-400">타겟 테이블 선택 필요</div>
|
||||||
|
) : (
|
||||||
|
targetColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.columnLabel || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operand.type === "static" && (
|
||||||
|
<Input
|
||||||
|
value={operand.value ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...operand, value: e.target.value })}
|
||||||
|
placeholder="값 입력"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{operand.type === "result" && (
|
||||||
|
<Select
|
||||||
|
value={operand.resultField || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const rf = resultFields.find((f) => f.name === value);
|
||||||
|
onChange({ ...operand, resultField: value, fieldLabel: rf?.label });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="이전 결과 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resultFields.map((f) => (
|
||||||
|
<SelectItem key={f.name} value={f.name}>
|
||||||
|
{f.label || f.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-4 p-4 pb-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-orange-50 p-2">
|
||||||
|
<Calculator className="h-4 w-4 text-orange-600" />
|
||||||
|
<span className="font-semibold text-orange-600">수식 변환 노드</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="displayName" className="text-xs">
|
||||||
|
표시 이름
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
saveToNode({ displayName: e.target.value });
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="노드 표시 이름"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타겟 테이블 조회 설정 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-blue-600" />
|
||||||
|
<h3 className="text-sm font-semibold">타겟 테이블 조회 (선택)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-xs text-gray-500">
|
||||||
|
UPSERT 시 기존 값을 참조하려면 타겟 테이블을 설정하세요. 수식에서 target.* 로 참조 가능합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 타겟 테이블 선택 */}
|
||||||
|
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tablesOpen}
|
||||||
|
className="mb-3 w-full justify-between"
|
||||||
|
disabled={tablesLoading}
|
||||||
|
>
|
||||||
|
{tablesLoading ? (
|
||||||
|
<span className="text-muted-foreground">로딩 중...</span>
|
||||||
|
) : targetLookup?.tableName ? (
|
||||||
|
<span>{targetLookup.tableLabel || targetLookup.tableName}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">타겟 테이블 선택 (선택사항)</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => handleTargetTableSelect(table.tableName)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
targetLookup?.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 조회 키 설정 */}
|
||||||
|
{targetLookup?.tableName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">조회 키 (source → target)</Label>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleAddLookupKey} className="h-6 px-2 text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(targetLookup.lookupKeys || []).length === 0 ? (
|
||||||
|
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||||
|
조회 키를 추가하세요 (예: item_code, lot_number)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{targetLookup.lookupKeys.map((key, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
|
||||||
|
<Select
|
||||||
|
value={key.sourceField}
|
||||||
|
onValueChange={(v) => handleLookupKeyChange(idx, "sourceField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="소스 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceFields.map((f) => (
|
||||||
|
<SelectItem key={f.name} value={f.name}>
|
||||||
|
{f.label || f.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={key.targetField}
|
||||||
|
onValueChange={(v) => handleLookupKeyChange(idx, "targetField", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="타겟 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.columnLabel || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveLookupKey(idx)}
|
||||||
|
className="h-6 w-6 p-0 text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 변환 규칙 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calculator className="h-4 w-4 text-orange-600" />
|
||||||
|
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transformations.length === 0 ? (
|
||||||
|
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||||
|
변환 규칙을 추가하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transformations.map((trans, index) => (
|
||||||
|
<div key={trans.id || index} className="rounded border bg-orange-50 p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-orange-700">변환 #{index + 1}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveTransformation(index)}
|
||||||
|
className="h-6 w-6 p-0 text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 출력 필드명 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">출력 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={trans.outputField || ""}
|
||||||
|
onChange={(e) => handleTransformationChange(index, { outputField: e.target.value })}
|
||||||
|
placeholder="예: new_current_qty"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출력 필드 라벨 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">출력 필드 라벨 (선택)</Label>
|
||||||
|
<Input
|
||||||
|
value={trans.outputFieldLabel || ""}
|
||||||
|
onChange={(e) => handleTransformationChange(index, { outputFieldLabel: e.target.value })}
|
||||||
|
placeholder="예: 새 현재고량"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수식 타입 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">수식 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={trans.formulaType}
|
||||||
|
onValueChange={(value) => handleFormulaTypeChange(index, value as FormulaType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FORMULA_TYPES.map((ft) => (
|
||||||
|
<SelectItem key={ft.value} value={ft.value}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{ft.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{ft.description}</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수식 타입별 설정 */}
|
||||||
|
{trans.formulaType === "arithmetic" && trans.arithmetic && (
|
||||||
|
<div className="space-y-2 rounded border bg-white p-2">
|
||||||
|
<Label className="text-xs text-gray-600">산술 연산</Label>
|
||||||
|
|
||||||
|
{/* 좌측 피연산자 */}
|
||||||
|
<div className="rounded bg-gray-50 p-2">
|
||||||
|
<div className="mb-1 text-xs text-gray-500">좌측</div>
|
||||||
|
{renderOperandSelector(
|
||||||
|
trans.arithmetic.leftOperand,
|
||||||
|
(updates) => {
|
||||||
|
const newArithmetic = { ...trans.arithmetic!, leftOperand: updates };
|
||||||
|
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연산자 */}
|
||||||
|
<Select
|
||||||
|
value={trans.arithmetic.operator}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newArithmetic = { ...trans.arithmetic!, operator: value as any };
|
||||||
|
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ARITHMETIC_OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 우측 피연산자 */}
|
||||||
|
<div className="rounded bg-gray-50 p-2">
|
||||||
|
<div className="mb-1 text-xs text-gray-500">우측</div>
|
||||||
|
{renderOperandSelector(
|
||||||
|
trans.arithmetic.rightOperand,
|
||||||
|
(updates) => {
|
||||||
|
const newArithmetic = { ...trans.arithmetic!, rightOperand: updates };
|
||||||
|
handleTransformationChange(index, { arithmetic: newArithmetic });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trans.formulaType === "function" && trans.function && (
|
||||||
|
<div className="space-y-2 rounded border bg-white p-2">
|
||||||
|
<Label className="text-xs text-gray-600">함수</Label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={trans.function.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const funcDef = FUNCTIONS.find((f) => f.value === value);
|
||||||
|
const argCount = funcDef?.argCount || 0;
|
||||||
|
const newArgs =
|
||||||
|
argCount === 0
|
||||||
|
? []
|
||||||
|
: Array(argCount === -1 ? 2 : argCount)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => ({ type: "source" as const, field: "" }));
|
||||||
|
handleTransformationChange(index, {
|
||||||
|
function: { name: value as any, arguments: newArgs },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FUNCTIONS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value}>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono font-medium">{f.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{f.description}</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 함수 인자들 */}
|
||||||
|
{trans.function.arguments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trans.function.arguments.map((arg, argIdx) => (
|
||||||
|
<div key={argIdx} className="rounded bg-gray-50 p-2">
|
||||||
|
<div className="mb-1 text-xs text-gray-500">인자 {argIdx + 1}</div>
|
||||||
|
{renderOperandSelector(
|
||||||
|
arg,
|
||||||
|
(updates) => {
|
||||||
|
const newArgs = [...trans.function!.arguments];
|
||||||
|
newArgs[argIdx] = updates;
|
||||||
|
handleTransformationChange(index, {
|
||||||
|
function: { ...trans.function!, arguments: newArgs },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trans.formulaType === "condition" && trans.condition && (
|
||||||
|
<div className="space-y-2 rounded border bg-white p-2">
|
||||||
|
<Label className="text-xs text-gray-600">조건 (CASE WHEN)</Label>
|
||||||
|
|
||||||
|
{/* WHEN 절 */}
|
||||||
|
<div className="rounded bg-yellow-50 p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-yellow-700">WHEN</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{renderOperandSelector(
|
||||||
|
trans.condition.when.leftOperand,
|
||||||
|
(updates) => {
|
||||||
|
const newCondition = {
|
||||||
|
...trans.condition!,
|
||||||
|
when: { ...trans.condition!.when, leftOperand: updates },
|
||||||
|
};
|
||||||
|
handleTransformationChange(index, { condition: newCondition });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={trans.condition.when.operator}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newCondition = {
|
||||||
|
...trans.condition!,
|
||||||
|
when: { ...trans.condition!.when, operator: value as any },
|
||||||
|
};
|
||||||
|
handleTransformationChange(index, { condition: newCondition });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONDITION_OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{!["IS_NULL", "IS_NOT_NULL"].includes(trans.condition.when.operator) &&
|
||||||
|
trans.condition.when.rightOperand &&
|
||||||
|
renderOperandSelector(
|
||||||
|
trans.condition.when.rightOperand,
|
||||||
|
(updates) => {
|
||||||
|
const newCondition = {
|
||||||
|
...trans.condition!,
|
||||||
|
when: { ...trans.condition!.when, rightOperand: updates },
|
||||||
|
};
|
||||||
|
handleTransformationChange(index, { condition: newCondition });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* THEN 절 */}
|
||||||
|
<div className="rounded bg-green-50 p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-green-700">THEN</div>
|
||||||
|
{renderOperandSelector(
|
||||||
|
trans.condition.then,
|
||||||
|
(updates) => {
|
||||||
|
const newCondition = { ...trans.condition!, then: updates };
|
||||||
|
handleTransformationChange(index, { condition: newCondition });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ELSE 절 */}
|
||||||
|
<div className="rounded bg-red-50 p-2">
|
||||||
|
<div className="mb-1 text-xs font-medium text-red-700">ELSE</div>
|
||||||
|
{renderOperandSelector(
|
||||||
|
trans.condition.else,
|
||||||
|
(updates) => {
|
||||||
|
const newCondition = { ...trans.condition!, else: updates };
|
||||||
|
handleTransformationChange(index, { condition: newCondition });
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trans.formulaType === "static" && (
|
||||||
|
<div className="space-y-2 rounded border bg-white p-2">
|
||||||
|
<Label className="text-xs text-gray-600">정적 값</Label>
|
||||||
|
<Input
|
||||||
|
value={trans.staticValue ?? ""}
|
||||||
|
onChange={(e) => handleTransformationChange(index, { staticValue: e.target.value })}
|
||||||
|
placeholder="고정 값 입력"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">문자열, 숫자, true/false 입력 가능</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,568 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 요청 노드 속성 편집
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2, Globe, Key, FileJson, Settings } from "lucide-react";
|
||||||
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
|
import type { HttpRequestActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
interface HttpRequestActionPropertiesProps {
|
||||||
|
nodeId: string;
|
||||||
|
data: HttpRequestActionNodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HttpRequestActionProperties({ nodeId, data }: HttpRequestActionPropertiesProps) {
|
||||||
|
const { updateNode } = useFlowEditorStore();
|
||||||
|
|
||||||
|
// 로컬 상태
|
||||||
|
const [displayName, setDisplayName] = useState(data.displayName || "HTTP 요청");
|
||||||
|
const [url, setUrl] = useState(data.url || "");
|
||||||
|
const [method, setMethod] = useState<HttpRequestActionNodeData["method"]>(data.method || "GET");
|
||||||
|
const [bodyType, setBodyType] = useState<HttpRequestActionNodeData["bodyType"]>(data.bodyType || "none");
|
||||||
|
const [body, setBody] = useState(data.body || "");
|
||||||
|
|
||||||
|
// 인증
|
||||||
|
const [authType, setAuthType] = useState<NonNullable<HttpRequestActionNodeData["authentication"]>["type"]>(
|
||||||
|
data.authentication?.type || "none"
|
||||||
|
);
|
||||||
|
const [authUsername, setAuthUsername] = useState(data.authentication?.username || "");
|
||||||
|
const [authPassword, setAuthPassword] = useState(data.authentication?.password || "");
|
||||||
|
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
||||||
|
const [authApiKey, setAuthApiKey] = useState(data.authentication?.apiKey || "");
|
||||||
|
const [authApiKeyName, setAuthApiKeyName] = useState(data.authentication?.apiKeyName || "X-API-Key");
|
||||||
|
const [authApiKeyLocation, setAuthApiKeyLocation] = useState<"header" | "query">(
|
||||||
|
data.authentication?.apiKeyLocation || "header"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 옵션
|
||||||
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
|
||||||
|
const [followRedirects, setFollowRedirects] = useState(data.options?.followRedirects ?? true);
|
||||||
|
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "0");
|
||||||
|
const [retryDelay, setRetryDelay] = useState(data.options?.retryDelay?.toString() || "1000");
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>(
|
||||||
|
Object.entries(data.headers || {}).map(([key, value]) => ({ key, value }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 쿼리 파라미터
|
||||||
|
const [queryParams, setQueryParams] = useState<Array<{ key: string; value: string }>>(
|
||||||
|
Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 응답 처리
|
||||||
|
const [extractPath, setExtractPath] = useState(data.responseHandling?.extractPath || "");
|
||||||
|
const [saveToVariable, setSaveToVariable] = useState(data.responseHandling?.saveToVariable || "");
|
||||||
|
|
||||||
|
// 데이터 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(data.displayName || "HTTP 요청");
|
||||||
|
setUrl(data.url || "");
|
||||||
|
setMethod(data.method || "GET");
|
||||||
|
setBodyType(data.bodyType || "none");
|
||||||
|
setBody(data.body || "");
|
||||||
|
setAuthType(data.authentication?.type || "none");
|
||||||
|
setAuthUsername(data.authentication?.username || "");
|
||||||
|
setAuthPassword(data.authentication?.password || "");
|
||||||
|
setAuthToken(data.authentication?.token || "");
|
||||||
|
setAuthApiKey(data.authentication?.apiKey || "");
|
||||||
|
setAuthApiKeyName(data.authentication?.apiKeyName || "X-API-Key");
|
||||||
|
setAuthApiKeyLocation(data.authentication?.apiKeyLocation || "header");
|
||||||
|
setTimeout(data.options?.timeout?.toString() || "30000");
|
||||||
|
setFollowRedirects(data.options?.followRedirects ?? true);
|
||||||
|
setRetryCount(data.options?.retryCount?.toString() || "0");
|
||||||
|
setRetryDelay(data.options?.retryDelay?.toString() || "1000");
|
||||||
|
setHeaders(Object.entries(data.headers || {}).map(([key, value]) => ({ key, value })));
|
||||||
|
setQueryParams(Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value })));
|
||||||
|
setExtractPath(data.responseHandling?.extractPath || "");
|
||||||
|
setSaveToVariable(data.responseHandling?.saveToVariable || "");
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 노드 업데이트 함수
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<HttpRequestActionNodeData>) => {
|
||||||
|
updateNode(nodeId, {
|
||||||
|
...data,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeId, data, updateNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 표시명 변경
|
||||||
|
const handleDisplayNameChange = (value: string) => {
|
||||||
|
setDisplayName(value);
|
||||||
|
updateNodeData({ displayName: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL 업데이트
|
||||||
|
const updateUrl = () => {
|
||||||
|
updateNodeData({ url });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메서드 변경
|
||||||
|
const handleMethodChange = (value: HttpRequestActionNodeData["method"]) => {
|
||||||
|
setMethod(value);
|
||||||
|
updateNodeData({ method: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 바디 타입 변경
|
||||||
|
const handleBodyTypeChange = (value: HttpRequestActionNodeData["bodyType"]) => {
|
||||||
|
setBodyType(value);
|
||||||
|
updateNodeData({ bodyType: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 바디 업데이트
|
||||||
|
const updateBody = () => {
|
||||||
|
updateNodeData({ body });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인증 업데이트
|
||||||
|
const updateAuthentication = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
authentication: {
|
||||||
|
type: authType,
|
||||||
|
username: authUsername || undefined,
|
||||||
|
password: authPassword || undefined,
|
||||||
|
token: authToken || undefined,
|
||||||
|
apiKey: authApiKey || undefined,
|
||||||
|
apiKeyName: authApiKeyName || undefined,
|
||||||
|
apiKeyLocation: authApiKeyLocation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [authType, authUsername, authPassword, authToken, authApiKey, authApiKeyName, authApiKeyLocation, updateNodeData]);
|
||||||
|
|
||||||
|
// 옵션 업데이트
|
||||||
|
const updateOptions = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
options: {
|
||||||
|
timeout: parseInt(timeout) || 30000,
|
||||||
|
followRedirects,
|
||||||
|
retryCount: parseInt(retryCount) || 0,
|
||||||
|
retryDelay: parseInt(retryDelay) || 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [timeout, followRedirects, retryCount, retryDelay, updateNodeData]);
|
||||||
|
|
||||||
|
// 응답 처리 업데이트
|
||||||
|
const updateResponseHandling = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
responseHandling: {
|
||||||
|
extractPath: extractPath || undefined,
|
||||||
|
saveToVariable: saveToVariable || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [extractPath, saveToVariable, updateNodeData]);
|
||||||
|
|
||||||
|
// 헤더 추가
|
||||||
|
const addHeader = () => {
|
||||||
|
setHeaders([...headers, { key: "", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 삭제
|
||||||
|
const removeHeader = (index: number) => {
|
||||||
|
const newHeaders = headers.filter((_, i) => i !== index);
|
||||||
|
setHeaders(newHeaders);
|
||||||
|
const headersObj = Object.fromEntries(newHeaders.filter(h => h.key).map(h => [h.key, h.value]));
|
||||||
|
updateNodeData({ headers: headersObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 업데이트
|
||||||
|
const updateHeader = (index: number, field: "key" | "value", value: string) => {
|
||||||
|
const newHeaders = [...headers];
|
||||||
|
newHeaders[index][field] = value;
|
||||||
|
setHeaders(newHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 저장
|
||||||
|
const saveHeaders = () => {
|
||||||
|
const headersObj = Object.fromEntries(headers.filter(h => h.key).map(h => [h.key, h.value]));
|
||||||
|
updateNodeData({ headers: headersObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쿼리 파라미터 추가
|
||||||
|
const addQueryParam = () => {
|
||||||
|
setQueryParams([...queryParams, { key: "", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쿼리 파라미터 삭제
|
||||||
|
const removeQueryParam = (index: number) => {
|
||||||
|
const newParams = queryParams.filter((_, i) => i !== index);
|
||||||
|
setQueryParams(newParams);
|
||||||
|
const paramsObj = Object.fromEntries(newParams.filter(p => p.key).map(p => [p.key, p.value]));
|
||||||
|
updateNodeData({ queryParams: paramsObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쿼리 파라미터 업데이트
|
||||||
|
const updateQueryParam = (index: number, field: "key" | "value", value: string) => {
|
||||||
|
const newParams = [...queryParams];
|
||||||
|
newParams[index][field] = value;
|
||||||
|
setQueryParams(newParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쿼리 파라미터 저장
|
||||||
|
const saveQueryParams = () => {
|
||||||
|
const paramsObj = Object.fromEntries(queryParams.filter(p => p.key).map(p => [p.key, p.value]));
|
||||||
|
updateNodeData({ queryParams: paramsObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시명</Label>
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||||
|
placeholder="HTTP 요청"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">URL *</Label>
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onBlur={updateUrl}
|
||||||
|
placeholder="https://api.example.com/endpoint"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메서드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">HTTP 메서드</Label>
|
||||||
|
<Select value={method} onValueChange={handleMethodChange}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="GET">GET</SelectItem>
|
||||||
|
<SelectItem value="POST">POST</SelectItem>
|
||||||
|
<SelectItem value="PUT">PUT</SelectItem>
|
||||||
|
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||||
|
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||||
|
<SelectItem value="HEAD">HEAD</SelectItem>
|
||||||
|
<SelectItem value="OPTIONS">OPTIONS</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="headers" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="headers" className="text-xs">
|
||||||
|
<Globe className="mr-1 h-3 w-3" />
|
||||||
|
헤더
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="body" className="text-xs">
|
||||||
|
<FileJson className="mr-1 h-3 w-3" />
|
||||||
|
바디
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="auth" className="text-xs">
|
||||||
|
<Key className="mr-1 h-3 w-3" />
|
||||||
|
인증
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="options" className="text-xs">
|
||||||
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
|
옵션
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 헤더 탭 */}
|
||||||
|
<TabsContent value="headers" className="space-y-3 pt-3">
|
||||||
|
{/* 쿼리 파라미터 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">쿼리 파라미터</Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addQueryParam}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{queryParams.map((param, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={param.key}
|
||||||
|
onChange={(e) => updateQueryParam(index, "key", e.target.value)}
|
||||||
|
onBlur={saveQueryParams}
|
||||||
|
placeholder="파라미터명"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={param.value}
|
||||||
|
onChange={(e) => updateQueryParam(index, "value", e.target.value)}
|
||||||
|
onBlur={saveQueryParams}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeQueryParam(index)}>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">요청 헤더</Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addHeader}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{headers.map((header, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={header.key}
|
||||||
|
onChange={(e) => updateHeader(index, "key", e.target.value)}
|
||||||
|
onBlur={saveHeaders}
|
||||||
|
placeholder="헤더명"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={header.value}
|
||||||
|
onChange={(e) => updateHeader(index, "value", e.target.value)}
|
||||||
|
onBlur={saveHeaders}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeHeader(index)}>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 바디 탭 */}
|
||||||
|
<TabsContent value="body" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">바디 타입</Label>
|
||||||
|
<Select value={bodyType} onValueChange={handleBodyTypeChange}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음</SelectItem>
|
||||||
|
<SelectItem value="json">JSON</SelectItem>
|
||||||
|
<SelectItem value="form">Form Data</SelectItem>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bodyType !== "none" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">요청 바디</Label>
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onBlur={updateBody}
|
||||||
|
placeholder={bodyType === "json" ? '{\n "key": "value"\n}' : "바디 내용"}
|
||||||
|
className="min-h-[200px] font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="bg-gray-50">
|
||||||
|
<CardContent className="p-3 text-xs text-gray-600">
|
||||||
|
<div className="font-medium mb-1">템플릿 변수 사용:</div>
|
||||||
|
<code className="block">{"{{sourceData}}"}</code>
|
||||||
|
<code className="block">{"{{필드명}}"}</code>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 인증 탭 */}
|
||||||
|
<TabsContent value="auth" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">인증 방식</Label>
|
||||||
|
<Select value={authType} onValueChange={(v: any) => {
|
||||||
|
setAuthType(v);
|
||||||
|
updateNodeData({ authentication: { ...data.authentication, type: v } });
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">인증 없음</SelectItem>
|
||||||
|
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||||
|
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||||
|
<SelectItem value="apikey">API Key</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authType === "basic" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">사용자명</Label>
|
||||||
|
<Input
|
||||||
|
value={authUsername}
|
||||||
|
onChange={(e) => setAuthUsername(e.target.value)}
|
||||||
|
onBlur={updateAuthentication}
|
||||||
|
placeholder="username"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">비밀번호</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e) => setAuthPassword(e.target.value)}
|
||||||
|
onBlur={updateAuthentication}
|
||||||
|
placeholder="password"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authType === "bearer" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Bearer Token</Label>
|
||||||
|
<Input
|
||||||
|
value={authToken}
|
||||||
|
onChange={(e) => setAuthToken(e.target.value)}
|
||||||
|
onBlur={updateAuthentication}
|
||||||
|
placeholder="your-token-here"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authType === "apikey" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">API Key</Label>
|
||||||
|
<Input
|
||||||
|
value={authApiKey}
|
||||||
|
onChange={(e) => setAuthApiKey(e.target.value)}
|
||||||
|
onBlur={updateAuthentication}
|
||||||
|
placeholder="your-api-key"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">헤더/파라미터 이름</Label>
|
||||||
|
<Input
|
||||||
|
value={authApiKeyName}
|
||||||
|
onChange={(e) => setAuthApiKeyName(e.target.value)}
|
||||||
|
onBlur={updateAuthentication}
|
||||||
|
placeholder="X-API-Key"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">위치</Label>
|
||||||
|
<Select value={authApiKeyLocation} onValueChange={(v: "header" | "query") => {
|
||||||
|
setAuthApiKeyLocation(v);
|
||||||
|
updateNodeData({
|
||||||
|
authentication: { ...data.authentication, type: authType, apiKeyLocation: v },
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="header">헤더</SelectItem>
|
||||||
|
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 옵션 탭 */}
|
||||||
|
<TabsContent value="options" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={timeout}
|
||||||
|
onChange={(e) => setTimeout(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="30000"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={followRedirects}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setFollowRedirects(checked);
|
||||||
|
updateNodeData({ options: { ...data.options, followRedirects: checked } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">리다이렉트 따라가기</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">재시도 횟수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={retryCount}
|
||||||
|
onChange={(e) => setRetryCount(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="0"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">재시도 간격 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={retryDelay}
|
||||||
|
onChange={(e) => setRetryDelay(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="1000"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">응답 데이터 추출 경로 (JSONPath)</Label>
|
||||||
|
<Input
|
||||||
|
value={extractPath}
|
||||||
|
onChange={(e) => setExtractPath(e.target.value)}
|
||||||
|
onBlur={updateResponseHandling}
|
||||||
|
placeholder="data.items"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">결과 저장 변수명</Label>
|
||||||
|
<Input
|
||||||
|
value={saveToVariable}
|
||||||
|
onChange={(e) => setSaveToVariable(e.target.value)}
|
||||||
|
onBlur={updateResponseHandling}
|
||||||
|
placeholder="apiResponse"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -236,7 +236,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
// 3️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||||
|
else if (node.type === "formulaTransform") {
|
||||||
|
console.log("✅ 수식 변환 노드 발견");
|
||||||
|
|
||||||
|
// 상위 노드의 필드 가져오기
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
|
// 수식 변환 출력 필드 추가
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||||
|
console.log(` 📊 ${nodeData.transformations.length}개 수식 변환 발견`);
|
||||||
|
nodeData.transformations.forEach((trans: any) => {
|
||||||
|
if (trans.outputField) {
|
||||||
|
fields.push({
|
||||||
|
name: trans.outputField,
|
||||||
|
label: trans.outputFieldLabel || trans.outputField,
|
||||||
|
sourcePath: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||||
else if (node.type === "aggregate") {
|
else if (node.type === "aggregate") {
|
||||||
console.log("✅ 집계 노드 발견");
|
console.log("✅ 집계 노드 발견");
|
||||||
const nodeData = node.data as any;
|
const nodeData = node.data as any;
|
||||||
|
|
@ -268,7 +292,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
if (outputFieldName) {
|
if (outputFieldName) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: outputFieldName,
|
name: outputFieldName,
|
||||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
label:
|
||||||
|
aggFunc.outputFieldLabel ||
|
||||||
|
aggFunc.targetFieldLabel ||
|
||||||
|
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||||
sourcePath: currentPath,
|
sourcePath: currentPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,706 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 참조 테이블 조회 노드 속성 편집
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
|
||||||
import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
||||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
|
||||||
|
|
||||||
// 필드 정의
|
|
||||||
interface FieldDefinition {
|
|
||||||
name: string;
|
|
||||||
label?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReferenceLookupPropertiesProps {
|
|
||||||
nodeId: string;
|
|
||||||
data: ReferenceLookupNodeData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS = [
|
|
||||||
{ value: "=", label: "같음 (=)" },
|
|
||||||
{ value: "!=", label: "같지 않음 (≠)" },
|
|
||||||
{ value: ">", label: "보다 큼 (>)" },
|
|
||||||
{ value: "<", label: "보다 작음 (<)" },
|
|
||||||
{ value: ">=", label: "크거나 같음 (≥)" },
|
|
||||||
{ value: "<=", label: "작거나 같음 (≤)" },
|
|
||||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
|
||||||
{ value: "IN", label: "IN" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) {
|
|
||||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
|
||||||
|
|
||||||
// 상태
|
|
||||||
const [displayName, setDisplayName] = useState(data.displayName || "참조 조회");
|
|
||||||
const [referenceTable, setReferenceTable] = useState(data.referenceTable || "");
|
|
||||||
const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || "");
|
|
||||||
const [joinConditions, setJoinConditions] = useState(data.joinConditions || []);
|
|
||||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
|
||||||
const [outputFields, setOutputFields] = useState(data.outputFields || []);
|
|
||||||
|
|
||||||
// 소스 필드 수집
|
|
||||||
const [sourceFields, setSourceFields] = useState<FieldDefinition[]>([]);
|
|
||||||
|
|
||||||
// 참조 테이블 관련
|
|
||||||
const [tables, setTables] = useState<any[]>([]);
|
|
||||||
const [tablesLoading, setTablesLoading] = useState(false);
|
|
||||||
const [tablesOpen, setTablesOpen] = useState(false);
|
|
||||||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
|
||||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Combobox 열림 상태 관리
|
|
||||||
const [whereFieldOpenState, setWhereFieldOpenState] = useState<boolean[]>([]);
|
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 동기화
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayName(data.displayName || "참조 조회");
|
|
||||||
setReferenceTable(data.referenceTable || "");
|
|
||||||
setReferenceTableLabel(data.referenceTableLabel || "");
|
|
||||||
setJoinConditions(data.joinConditions || []);
|
|
||||||
setWhereConditions(data.whereConditions || []);
|
|
||||||
setOutputFields(data.outputFields || []);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// whereConditions 변경 시 whereFieldOpenState 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
setWhereFieldOpenState(new Array(whereConditions.length).fill(false));
|
|
||||||
}, [whereConditions.length]);
|
|
||||||
|
|
||||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
|
||||||
useEffect(() => {
|
|
||||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
|
||||||
const fields: FieldDefinition[] = [];
|
|
||||||
|
|
||||||
for (const edge of incomingEdges) {
|
|
||||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
|
||||||
if (!sourceNode) continue;
|
|
||||||
|
|
||||||
const sourceData = sourceNode.data as any;
|
|
||||||
|
|
||||||
if (sourceNode.type === "tableSource" && sourceData.fields) {
|
|
||||||
fields.push(...sourceData.fields);
|
|
||||||
} else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) {
|
|
||||||
fields.push(...sourceData.outputFields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSourceFields(fields);
|
|
||||||
}, [nodeId, nodes, edges]);
|
|
||||||
|
|
||||||
// 📊 테이블 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
loadTables();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTables = async () => {
|
|
||||||
setTablesLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await tableTypeApi.getTables();
|
|
||||||
setTables(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("테이블 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setTablesLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 📋 참조 테이블 컬럼 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (referenceTable) {
|
|
||||||
loadReferenceColumns();
|
|
||||||
} else {
|
|
||||||
setReferenceColumns([]);
|
|
||||||
}
|
|
||||||
}, [referenceTable]);
|
|
||||||
|
|
||||||
const loadReferenceColumns = async () => {
|
|
||||||
if (!referenceTable) return;
|
|
||||||
|
|
||||||
setColumnsLoading(true);
|
|
||||||
try {
|
|
||||||
const cols = await tableTypeApi.getColumns(referenceTable);
|
|
||||||
const formatted = cols.map((col: any) => ({
|
|
||||||
name: col.columnName,
|
|
||||||
type: col.dataType,
|
|
||||||
label: col.displayName || col.columnName,
|
|
||||||
}));
|
|
||||||
setReferenceColumns(formatted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컬럼 로드 실패:", error);
|
|
||||||
setReferenceColumns([]);
|
|
||||||
} finally {
|
|
||||||
setColumnsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테이블 선택 핸들러
|
|
||||||
const handleTableSelect = (tableName: string) => {
|
|
||||||
const selectedTable = tables.find((t) => t.tableName === tableName);
|
|
||||||
if (selectedTable) {
|
|
||||||
setReferenceTable(tableName);
|
|
||||||
setReferenceTableLabel(selectedTable.label);
|
|
||||||
setTablesOpen(false);
|
|
||||||
|
|
||||||
// 기존 설정 초기화
|
|
||||||
setJoinConditions([]);
|
|
||||||
setWhereConditions([]);
|
|
||||||
setOutputFields([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 조인 조건 추가
|
|
||||||
const handleAddJoinCondition = () => {
|
|
||||||
setJoinConditions([
|
|
||||||
...joinConditions,
|
|
||||||
{
|
|
||||||
sourceField: "",
|
|
||||||
referenceField: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveJoinCondition = (index: number) => {
|
|
||||||
setJoinConditions(joinConditions.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJoinConditionChange = (index: number, field: string, value: any) => {
|
|
||||||
const newConditions = [...joinConditions];
|
|
||||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
|
||||||
|
|
||||||
// 라벨도 함께 저장
|
|
||||||
if (field === "sourceField") {
|
|
||||||
const sourceField = sourceFields.find((f) => f.name === value);
|
|
||||||
newConditions[index].sourceFieldLabel = sourceField?.label || value;
|
|
||||||
} else if (field === "referenceField") {
|
|
||||||
const refField = referenceColumns.find((f) => f.name === value);
|
|
||||||
newConditions[index].referenceFieldLabel = refField?.label || value;
|
|
||||||
}
|
|
||||||
|
|
||||||
setJoinConditions(newConditions);
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 추가
|
|
||||||
const handleAddWhereCondition = () => {
|
|
||||||
const newConditions = [
|
|
||||||
...whereConditions,
|
|
||||||
{
|
|
||||||
field: "",
|
|
||||||
operator: "=",
|
|
||||||
value: "",
|
|
||||||
valueType: "static",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setWhereConditions(newConditions);
|
|
||||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveWhereCondition = (index: number) => {
|
|
||||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
|
||||||
setWhereConditions(newConditions);
|
|
||||||
setWhereFieldOpenState(new Array(newConditions.length).fill(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
|
||||||
const newConditions = [...whereConditions];
|
|
||||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
|
||||||
|
|
||||||
// 라벨도 함께 저장
|
|
||||||
if (field === "field") {
|
|
||||||
const refField = referenceColumns.find((f) => f.name === value);
|
|
||||||
newConditions[index].fieldLabel = refField?.label || value;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWhereConditions(newConditions);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 출력 필드 추가
|
|
||||||
const handleAddOutputField = () => {
|
|
||||||
setOutputFields([
|
|
||||||
...outputFields,
|
|
||||||
{
|
|
||||||
fieldName: "",
|
|
||||||
alias: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveOutputField = (index: number) => {
|
|
||||||
setOutputFields(outputFields.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOutputFieldChange = (index: number, field: string, value: any) => {
|
|
||||||
const newFields = [...outputFields];
|
|
||||||
newFields[index] = { ...newFields[index], [field]: value };
|
|
||||||
|
|
||||||
// 라벨도 함께 저장
|
|
||||||
if (field === "fieldName") {
|
|
||||||
const refField = referenceColumns.find((f) => f.name === value);
|
|
||||||
newFields[index].fieldLabel = refField?.label || value;
|
|
||||||
// alias 자동 설정
|
|
||||||
if (!newFields[index].alias) {
|
|
||||||
newFields[index].alias = `ref_${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOutputFields(newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateNode(nodeId, {
|
|
||||||
displayName,
|
|
||||||
referenceTable,
|
|
||||||
referenceTableLabel,
|
|
||||||
joinConditions,
|
|
||||||
whereConditions,
|
|
||||||
outputFields,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="space-y-4 p-4 pb-8">
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="displayName" className="text-xs">
|
|
||||||
표시 이름
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="displayName"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="노드 표시 이름"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 참조 테이블 선택 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">참조 테이블</Label>
|
|
||||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={tablesOpen}
|
|
||||||
className="mt-1 w-full justify-between"
|
|
||||||
disabled={tablesLoading}
|
|
||||||
>
|
|
||||||
{tablesLoading ? (
|
|
||||||
<span className="text-muted-foreground">로딩 중...</span>
|
|
||||||
) : referenceTable ? (
|
|
||||||
<span className="truncate">{selectedTableLabel}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">테이블 선택...</span>
|
|
||||||
)}
|
|
||||||
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<ScrollArea className="h-[300px]">
|
|
||||||
{tables.map((table) => (
|
|
||||||
<CommandItem
|
|
||||||
key={table.tableName}
|
|
||||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
|
||||||
onSelect={() => handleTableSelect(table.tableName)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
referenceTable === table.tableName ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{table.label}</span>
|
|
||||||
{table.label !== table.tableName && (
|
|
||||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</ScrollArea>
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 조인 조건 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">조인 조건 (FK 매핑)</h3>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleAddJoinCondition}
|
|
||||||
className="h-7"
|
|
||||||
disabled={!referenceTable}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{joinConditions.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{joinConditions.map((condition, index) => (
|
|
||||||
<div key={index} className="rounded border bg-purple-50 p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-purple-700">조인 #{index + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleRemoveJoinCondition(index)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
|
||||||
<Select
|
|
||||||
value={condition.sourceField}
|
|
||||||
onValueChange={(value) => handleJoinConditionChange(index, "sourceField", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{sourceFields.map((field) => (
|
|
||||||
<SelectItem key={field.name} value={field.name}>
|
|
||||||
{field.label || field.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">참조 필드</Label>
|
|
||||||
<Select
|
|
||||||
value={condition.referenceField}
|
|
||||||
onValueChange={(value) => handleJoinConditionChange(index, "referenceField", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue placeholder="참조 필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{referenceColumns.map((field) => (
|
|
||||||
<SelectItem key={field.name} value={field.name}>
|
|
||||||
{field.label || field.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
|
||||||
조인 조건을 추가하세요 (필수)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* WHERE 조건 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">WHERE 조건 (선택사항)</h3>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleAddWhereCondition}
|
|
||||||
className="h-7"
|
|
||||||
disabled={!referenceTable}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{whereConditions.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{whereConditions.map((condition, index) => (
|
|
||||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-yellow-700">WHERE #{index + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleRemoveWhereCondition(index)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 필드 - Combobox */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">필드</Label>
|
|
||||||
<Popover
|
|
||||||
open={whereFieldOpenState[index]}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
const newState = [...whereFieldOpenState];
|
|
||||||
newState[index] = open;
|
|
||||||
setWhereFieldOpenState(newState);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={whereFieldOpenState[index]}
|
|
||||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
{condition.field
|
|
||||||
? (() => {
|
|
||||||
const field = referenceColumns.find((f) => f.name === condition.field);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
|
||||||
<span className="truncate">{field?.label || condition.field}</span>
|
|
||||||
{field?.type && (
|
|
||||||
<span className="text-muted-foreground text-xs">{field.type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
: "필드 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="p-0"
|
|
||||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{referenceColumns.map((field) => (
|
|
||||||
<CommandItem
|
|
||||||
key={field.name}
|
|
||||||
value={field.name}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
handleWhereConditionChange(index, "field", currentValue);
|
|
||||||
const newState = [...whereFieldOpenState];
|
|
||||||
newState[index] = false;
|
|
||||||
setWhereFieldOpenState(newState);
|
|
||||||
}}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
condition.field === field.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
|
||||||
{field.type && (
|
|
||||||
<span className="text-muted-foreground text-[10px]">{field.type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">연산자</Label>
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(value) => handleWhereConditionChange(index, "operator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{OPERATORS.map((op) => (
|
|
||||||
<SelectItem key={op.value} value={op.value}>
|
|
||||||
{op.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">값 타입</Label>
|
|
||||||
<Select
|
|
||||||
value={condition.valueType || "static"}
|
|
||||||
onValueChange={(value) => handleWhereConditionChange(index, "valueType", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
|
||||||
<SelectItem value="field">소스 필드 참조</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">
|
|
||||||
{condition.valueType === "field" ? "소스 필드" : "값"}
|
|
||||||
</Label>
|
|
||||||
{condition.valueType === "field" ? (
|
|
||||||
<Select
|
|
||||||
value={condition.value}
|
|
||||||
onValueChange={(value) => handleWhereConditionChange(index, "value", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{sourceFields.map((field) => (
|
|
||||||
<SelectItem key={field.name} value={field.name}>
|
|
||||||
{field.label || field.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={condition.value}
|
|
||||||
onChange={(e) => handleWhereConditionChange(index, "value", e.target.value)}
|
|
||||||
placeholder="비교할 값"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출력 필드 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">출력 필드</h3>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleAddOutputField}
|
|
||||||
className="h-7"
|
|
||||||
disabled={!referenceTable}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{outputFields.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{outputFields.map((field, index) => (
|
|
||||||
<div key={index} className="rounded border bg-blue-50 p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-blue-700">필드 #{index + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleRemoveOutputField(index)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">참조 테이블 필드</Label>
|
|
||||||
<Select
|
|
||||||
value={field.fieldName}
|
|
||||||
onValueChange={(value) => handleOutputFieldChange(index, "fieldName", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
||||||
<SelectValue placeholder="필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{referenceColumns.map((col) => (
|
|
||||||
<SelectItem key={col.name} value={col.name}>
|
|
||||||
{col.label || col.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">별칭 (Alias)</Label>
|
|
||||||
<Input
|
|
||||||
value={field.alias}
|
|
||||||
onChange={(e) => handleOutputFieldChange(index, "alias", e.target.value)}
|
|
||||||
placeholder="ref_field_name"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
|
||||||
출력 필드를 추가하세요 (필수)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 저장 버튼 */}
|
|
||||||
|
|
||||||
{/* 안내 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
|
||||||
🔗 <strong>조인 조건</strong>: 소스 데이터와 참조 테이블을 연결하는 키 (예: customer_id → id)
|
|
||||||
</div>
|
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
|
||||||
⚡ <strong>WHERE 조건</strong>: 참조 테이블에서 특정 조건의 데이터만 가져오기 (예: grade = 'VIP')
|
|
||||||
</div>
|
|
||||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
|
||||||
📤 <strong>출력 필드</strong>: 참조 테이블에서 가져올 필드 선택 (별칭으로 결과에 추가됨)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스크립트 실행 노드 속성 편집
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2, Terminal, FileCode, Settings, Play } from "lucide-react";
|
||||||
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
|
import type { ScriptActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
interface ScriptActionPropertiesProps {
|
||||||
|
nodeId: string;
|
||||||
|
data: ScriptActionNodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptActionProperties({ nodeId, data }: ScriptActionPropertiesProps) {
|
||||||
|
const { updateNode } = useFlowEditorStore();
|
||||||
|
|
||||||
|
// 로컬 상태
|
||||||
|
const [displayName, setDisplayName] = useState(data.displayName || "스크립트 실행");
|
||||||
|
const [scriptType, setScriptType] = useState<ScriptActionNodeData["scriptType"]>(data.scriptType || "python");
|
||||||
|
const [executionMode, setExecutionMode] = useState<"inline" | "file">(data.executionMode || "inline");
|
||||||
|
const [inlineScript, setInlineScript] = useState(data.inlineScript || "");
|
||||||
|
const [scriptPath, setScriptPath] = useState(data.scriptPath || "");
|
||||||
|
const [executablePath, setExecutablePath] = useState(data.executablePath || "");
|
||||||
|
const [inputMethod, setInputMethod] = useState<ScriptActionNodeData["inputMethod"]>(data.inputMethod || "stdin");
|
||||||
|
const [inputFormat, setInputFormat] = useState<"json" | "csv" | "text">(data.inputFormat || "json");
|
||||||
|
const [workingDirectory, setWorkingDirectory] = useState(data.workingDirectory || "");
|
||||||
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "60000");
|
||||||
|
const [maxBuffer, setMaxBuffer] = useState(data.options?.maxBuffer?.toString() || "1048576");
|
||||||
|
const [shell, setShell] = useState(data.options?.shell || "");
|
||||||
|
const [captureStdout, setCaptureStdout] = useState(data.outputHandling?.captureStdout ?? true);
|
||||||
|
const [captureStderr, setCaptureStderr] = useState(data.outputHandling?.captureStderr ?? true);
|
||||||
|
const [parseOutput, setParseOutput] = useState<"json" | "lines" | "text">(data.outputHandling?.parseOutput || "text");
|
||||||
|
|
||||||
|
// 환경변수
|
||||||
|
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
|
||||||
|
Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 명령줄 인자
|
||||||
|
const [args, setArgs] = useState<string[]>(data.arguments || []);
|
||||||
|
|
||||||
|
// 데이터 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(data.displayName || "스크립트 실행");
|
||||||
|
setScriptType(data.scriptType || "python");
|
||||||
|
setExecutionMode(data.executionMode || "inline");
|
||||||
|
setInlineScript(data.inlineScript || "");
|
||||||
|
setScriptPath(data.scriptPath || "");
|
||||||
|
setExecutablePath(data.executablePath || "");
|
||||||
|
setInputMethod(data.inputMethod || "stdin");
|
||||||
|
setInputFormat(data.inputFormat || "json");
|
||||||
|
setWorkingDirectory(data.workingDirectory || "");
|
||||||
|
setTimeout(data.options?.timeout?.toString() || "60000");
|
||||||
|
setMaxBuffer(data.options?.maxBuffer?.toString() || "1048576");
|
||||||
|
setShell(data.options?.shell || "");
|
||||||
|
setCaptureStdout(data.outputHandling?.captureStdout ?? true);
|
||||||
|
setCaptureStderr(data.outputHandling?.captureStderr ?? true);
|
||||||
|
setParseOutput(data.outputHandling?.parseOutput || "text");
|
||||||
|
setEnvVars(Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value })));
|
||||||
|
setArgs(data.arguments || []);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 노드 업데이트 함수
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<ScriptActionNodeData>) => {
|
||||||
|
updateNode(nodeId, {
|
||||||
|
...data,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nodeId, data, updateNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 표시명 변경
|
||||||
|
const handleDisplayNameChange = (value: string) => {
|
||||||
|
setDisplayName(value);
|
||||||
|
updateNodeData({ displayName: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스크립트 타입 변경
|
||||||
|
const handleScriptTypeChange = (value: ScriptActionNodeData["scriptType"]) => {
|
||||||
|
setScriptType(value);
|
||||||
|
updateNodeData({ scriptType: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 실행 모드 변경
|
||||||
|
const handleExecutionModeChange = (value: "inline" | "file") => {
|
||||||
|
setExecutionMode(value);
|
||||||
|
updateNodeData({ executionMode: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스크립트 내용 업데이트
|
||||||
|
const updateScriptContent = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
inlineScript,
|
||||||
|
scriptPath,
|
||||||
|
executablePath,
|
||||||
|
});
|
||||||
|
}, [inlineScript, scriptPath, executablePath, updateNodeData]);
|
||||||
|
|
||||||
|
// 입력 설정 업데이트
|
||||||
|
const updateInputSettings = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
inputMethod,
|
||||||
|
inputFormat,
|
||||||
|
workingDirectory: workingDirectory || undefined,
|
||||||
|
});
|
||||||
|
}, [inputMethod, inputFormat, workingDirectory, updateNodeData]);
|
||||||
|
|
||||||
|
// 옵션 업데이트
|
||||||
|
const updateOptions = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
options: {
|
||||||
|
timeout: parseInt(timeout) || 60000,
|
||||||
|
maxBuffer: parseInt(maxBuffer) || 1048576,
|
||||||
|
shell: shell || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [timeout, maxBuffer, shell, updateNodeData]);
|
||||||
|
|
||||||
|
// 출력 처리 업데이트
|
||||||
|
const updateOutputHandling = useCallback(() => {
|
||||||
|
updateNodeData({
|
||||||
|
outputHandling: {
|
||||||
|
captureStdout,
|
||||||
|
captureStderr,
|
||||||
|
parseOutput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [captureStdout, captureStderr, parseOutput, updateNodeData]);
|
||||||
|
|
||||||
|
// 환경변수 추가
|
||||||
|
const addEnvVar = () => {
|
||||||
|
const newEnvVars = [...envVars, { key: "", value: "" }];
|
||||||
|
setEnvVars(newEnvVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 환경변수 삭제
|
||||||
|
const removeEnvVar = (index: number) => {
|
||||||
|
const newEnvVars = envVars.filter((_, i) => i !== index);
|
||||||
|
setEnvVars(newEnvVars);
|
||||||
|
const envObj = Object.fromEntries(newEnvVars.filter(e => e.key).map(e => [e.key, e.value]));
|
||||||
|
updateNodeData({ environmentVariables: envObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 환경변수 업데이트
|
||||||
|
const updateEnvVar = (index: number, field: "key" | "value", value: string) => {
|
||||||
|
const newEnvVars = [...envVars];
|
||||||
|
newEnvVars[index][field] = value;
|
||||||
|
setEnvVars(newEnvVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 환경변수 저장
|
||||||
|
const saveEnvVars = () => {
|
||||||
|
const envObj = Object.fromEntries(envVars.filter(e => e.key).map(e => [e.key, e.value]));
|
||||||
|
updateNodeData({ environmentVariables: envObj });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인자 추가
|
||||||
|
const addArg = () => {
|
||||||
|
const newArgs = [...args, ""];
|
||||||
|
setArgs(newArgs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인자 삭제
|
||||||
|
const removeArg = (index: number) => {
|
||||||
|
const newArgs = args.filter((_, i) => i !== index);
|
||||||
|
setArgs(newArgs);
|
||||||
|
updateNodeData({ arguments: newArgs });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인자 업데이트
|
||||||
|
const updateArg = (index: number, value: string) => {
|
||||||
|
const newArgs = [...args];
|
||||||
|
newArgs[index] = value;
|
||||||
|
setArgs(newArgs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인자 저장
|
||||||
|
const saveArgs = () => {
|
||||||
|
updateNodeData({ arguments: args.filter(a => a) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스크립트 타입별 기본 스크립트 템플릿
|
||||||
|
const getScriptTemplate = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "python":
|
||||||
|
return `import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 입력 데이터 읽기 (stdin)
|
||||||
|
input_data = json.loads(sys.stdin.read())
|
||||||
|
|
||||||
|
# 처리 로직
|
||||||
|
result = {
|
||||||
|
"status": "success",
|
||||||
|
"data": input_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
print(json.dumps(result))`;
|
||||||
|
case "shell":
|
||||||
|
return `#!/bin/bash
|
||||||
|
|
||||||
|
# 입력 데이터 읽기
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
# 처리 로직
|
||||||
|
echo "입력 데이터: $INPUT"
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
echo '{"status": "success"}'`;
|
||||||
|
case "powershell":
|
||||||
|
return `# 입력 데이터 읽기
|
||||||
|
$input = $input | ConvertFrom-Json
|
||||||
|
|
||||||
|
# 처리 로직
|
||||||
|
$result = @{
|
||||||
|
status = "success"
|
||||||
|
data = $input
|
||||||
|
}
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
$result | ConvertTo-Json`;
|
||||||
|
case "node":
|
||||||
|
return `const readline = require('readline');
|
||||||
|
|
||||||
|
let input = '';
|
||||||
|
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
input += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
const data = JSON.parse(input);
|
||||||
|
|
||||||
|
// 처리 로직
|
||||||
|
const result = {
|
||||||
|
status: 'success',
|
||||||
|
data: data
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
});`;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시명</Label>
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||||
|
placeholder="스크립트 실행"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스크립트 타입 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">스크립트 타입</Label>
|
||||||
|
<Select value={scriptType} onValueChange={handleScriptTypeChange}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="python">Python</SelectItem>
|
||||||
|
<SelectItem value="shell">Shell (Bash)</SelectItem>
|
||||||
|
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||||
|
<SelectItem value="node">Node.js</SelectItem>
|
||||||
|
<SelectItem value="executable">실행 파일</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 모드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">실행 방식</Label>
|
||||||
|
<Select value={executionMode} onValueChange={handleExecutionModeChange}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inline">인라인 스크립트</SelectItem>
|
||||||
|
<SelectItem value="file">파일 실행</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="script" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="script" className="text-xs">
|
||||||
|
<FileCode className="mr-1 h-3 w-3" />
|
||||||
|
스크립트
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="input" className="text-xs">
|
||||||
|
<Play className="mr-1 h-3 w-3" />
|
||||||
|
입력
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="env" className="text-xs">
|
||||||
|
<Terminal className="mr-1 h-3 w-3" />
|
||||||
|
환경
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="output" className="text-xs">
|
||||||
|
<Settings className="mr-1 h-3 w-3" />
|
||||||
|
출력
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 스크립트 탭 */}
|
||||||
|
<TabsContent value="script" className="space-y-3 pt-3">
|
||||||
|
{executionMode === "inline" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">스크립트 코드</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => setInlineScript(getScriptTemplate(scriptType))}
|
||||||
|
>
|
||||||
|
템플릿 삽입
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={inlineScript}
|
||||||
|
onChange={(e) => setInlineScript(e.target.value)}
|
||||||
|
onBlur={updateScriptContent}
|
||||||
|
placeholder="스크립트 코드를 입력하세요..."
|
||||||
|
className="min-h-[250px] font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{scriptType === "executable" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">실행 파일 경로</Label>
|
||||||
|
<Input
|
||||||
|
value={executablePath}
|
||||||
|
onChange={(e) => setExecutablePath(e.target.value)}
|
||||||
|
onBlur={updateScriptContent}
|
||||||
|
placeholder="/path/to/executable"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">스크립트 파일 경로</Label>
|
||||||
|
<Input
|
||||||
|
value={scriptPath}
|
||||||
|
onChange={(e) => setScriptPath(e.target.value)}
|
||||||
|
onBlur={updateScriptContent}
|
||||||
|
placeholder={`/path/to/script.${scriptType === "python" ? "py" : scriptType === "shell" ? "sh" : scriptType === "powershell" ? "ps1" : "js"}`}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 명령줄 인자 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">명령줄 인자</Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addArg}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{args.map((arg, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={arg}
|
||||||
|
onChange={(e) => updateArg(index, e.target.value)}
|
||||||
|
onBlur={saveArgs}
|
||||||
|
placeholder={`인자 ${index + 1}`}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeArg(index)}>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 입력 탭 */}
|
||||||
|
<TabsContent value="input" className="space-y-3 pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">입력 전달 방식</Label>
|
||||||
|
<Select value={inputMethod} onValueChange={(v: ScriptActionNodeData["inputMethod"]) => {
|
||||||
|
setInputMethod(v);
|
||||||
|
updateNodeData({ inputMethod: v });
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stdin">표준입력 (stdin)</SelectItem>
|
||||||
|
<SelectItem value="args">명령줄 인자</SelectItem>
|
||||||
|
<SelectItem value="env">환경변수</SelectItem>
|
||||||
|
<SelectItem value="file">임시 파일</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(inputMethod === "stdin" || inputMethod === "file") && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">입력 형식</Label>
|
||||||
|
<Select value={inputFormat} onValueChange={(v: "json" | "csv" | "text") => {
|
||||||
|
setInputFormat(v);
|
||||||
|
updateNodeData({ inputFormat: v });
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="json">JSON</SelectItem>
|
||||||
|
<SelectItem value="csv">CSV</SelectItem>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">작업 디렉토리</Label>
|
||||||
|
<Input
|
||||||
|
value={workingDirectory}
|
||||||
|
onChange={(e) => setWorkingDirectory(e.target.value)}
|
||||||
|
onBlur={updateInputSettings}
|
||||||
|
placeholder="/path/to/working/directory"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={timeout}
|
||||||
|
onChange={(e) => setTimeout(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="60000"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 환경변수 탭 */}
|
||||||
|
<TabsContent value="env" className="space-y-3 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">환경변수</Label>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addEnvVar}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envVars.map((env, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={env.key}
|
||||||
|
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
|
||||||
|
onBlur={saveEnvVars}
|
||||||
|
placeholder="변수명"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={env.value}
|
||||||
|
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
|
||||||
|
onBlur={saveEnvVars}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeEnvVar(index)}>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{envVars.length === 0 && (
|
||||||
|
<Card className="bg-gray-50">
|
||||||
|
<CardContent className="p-3 text-xs text-gray-500">
|
||||||
|
환경변수가 없습니다. 추가 버튼을 클릭하여 환경변수를 설정하세요.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">사용할 쉘</Label>
|
||||||
|
<Input
|
||||||
|
value={shell}
|
||||||
|
onChange={(e) => setShell(e.target.value)}
|
||||||
|
onBlur={updateOptions}
|
||||||
|
placeholder="/bin/bash (기본값 사용 시 비워두기)"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 출력 탭 */}
|
||||||
|
<TabsContent value="output" className="space-y-3 pt-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={captureStdout}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setCaptureStdout(checked);
|
||||||
|
updateNodeData({
|
||||||
|
outputHandling: { ...data.outputHandling, captureStdout: checked, captureStderr, parseOutput },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">표준출력 (stdout) 캡처</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={captureStderr}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setCaptureStderr(checked);
|
||||||
|
updateNodeData({
|
||||||
|
outputHandling: { ...data.outputHandling, captureStdout, captureStderr: checked, parseOutput },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">표준에러 (stderr) 캡처</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">출력 파싱</Label>
|
||||||
|
<Select value={parseOutput} onValueChange={(v: "json" | "lines" | "text") => {
|
||||||
|
setParseOutput(v);
|
||||||
|
updateNodeData({
|
||||||
|
outputHandling: { ...data.outputHandling, captureStdout, captureStderr, parseOutput: v },
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="json">JSON 파싱</SelectItem>
|
||||||
|
<SelectItem value="lines">줄 단위 배열</SelectItem>
|
||||||
|
<SelectItem value="text">텍스트 그대로</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-gray-50">
|
||||||
|
<CardContent className="p-3 text-xs text-gray-600">
|
||||||
|
<div className="font-medium mb-1">출력 데이터 사용:</div>
|
||||||
|
스크립트의 출력은 다음 노드에서 <code>{"{{scriptOutput}}"}</code>로 참조할 수 있습니다.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +212,27 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
// 2️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||||
|
else if (node.type === "formulaTransform") {
|
||||||
|
// 상위 노드의 필드 가져오기
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
|
// 수식 변환 출력 필드 추가
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||||
|
nodeData.transformations.forEach((trans: any) => {
|
||||||
|
if (trans.outputField) {
|
||||||
|
fields.push({
|
||||||
|
name: trans.outputField,
|
||||||
|
label: trans.outputFieldLabel || trans.outputField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||||
else if (node.type === "aggregate") {
|
else if (node.type === "aggregate") {
|
||||||
const nodeData = node.data as any;
|
const nodeData = node.data as any;
|
||||||
|
|
||||||
|
|
@ -240,7 +260,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
if (outputFieldName) {
|
if (outputFieldName) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: outputFieldName,
|
name: outputFieldName,
|
||||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
label:
|
||||||
|
aggFunc.outputFieldLabel ||
|
||||||
|
aggFunc.targetFieldLabel ||
|
||||||
|
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -248,7 +271,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
|
|
||||||
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
|
||||||
}
|
}
|
||||||
// 3️⃣ REST API 소스 노드
|
// 4️⃣ REST API 소스 노드
|
||||||
else if (node.type === "restAPISource") {
|
else if (node.type === "restAPISource") {
|
||||||
foundRestAPI = true;
|
foundRestAPI = true;
|
||||||
const responseFields = (node.data as any).responseFields;
|
const responseFields = (node.data as any).responseFields;
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
// 3️⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
|
||||||
|
else if (node.type === "formulaTransform") {
|
||||||
|
// 상위 노드의 필드 가져오기
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
|
// 수식 변환 출력 필드 추가
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
|
||||||
|
nodeData.transformations.forEach((trans: any) => {
|
||||||
|
if (trans.outputField) {
|
||||||
|
fields.push({
|
||||||
|
name: trans.outputField,
|
||||||
|
label: trans.outputFieldLabel || trans.outputField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
|
||||||
else if (node.type === "aggregate") {
|
else if (node.type === "aggregate") {
|
||||||
const nodeData = node.data as any;
|
const nodeData = node.data as any;
|
||||||
|
|
||||||
|
|
@ -240,7 +260,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
if (outputFieldName) {
|
if (outputFieldName) {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: outputFieldName,
|
name: outputFieldName,
|
||||||
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
label:
|
||||||
|
aggFunc.outputFieldLabel ||
|
||||||
|
aggFunc.targetFieldLabel ||
|
||||||
|
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||||
category: "source",
|
category: "source",
|
||||||
color: "#10B981", // 초록색
|
color: "#10B981", // 초록색
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "referenceLookup",
|
|
||||||
label: "참조 조회",
|
|
||||||
icon: "",
|
|
||||||
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
|
|
||||||
category: "source",
|
|
||||||
color: "#A855F7", // 보라색
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 변환/조건
|
// 변환/조건
|
||||||
|
|
@ -68,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||||
category: "transform",
|
category: "transform",
|
||||||
color: "#A855F7", // 보라색
|
color: "#A855F7", // 보라색
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "formulaTransform",
|
||||||
|
label: "수식 변환",
|
||||||
|
icon: "",
|
||||||
|
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
|
||||||
|
category: "transform",
|
||||||
|
color: "#F97316", // 오렌지색
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 액션
|
// 액션
|
||||||
|
|
@ -105,6 +105,34 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||||
color: "#8B5CF6", // 보라색
|
color: "#8B5CF6", // 보라색
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 외부 연동
|
||||||
|
// ========================================================================
|
||||||
|
{
|
||||||
|
type: "emailAction",
|
||||||
|
label: "메일 발송",
|
||||||
|
icon: "",
|
||||||
|
description: "SMTP를 통해 이메일을 발송합니다",
|
||||||
|
category: "external",
|
||||||
|
color: "#EC4899", // 핑크색
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "scriptAction",
|
||||||
|
label: "스크립트 실행",
|
||||||
|
icon: "",
|
||||||
|
description: "Python, Shell 등 외부 스크립트를 실행합니다",
|
||||||
|
category: "external",
|
||||||
|
color: "#10B981", // 에메랄드
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "httpRequestAction",
|
||||||
|
label: "HTTP 요청",
|
||||||
|
icon: "",
|
||||||
|
description: "REST API를 호출합니다",
|
||||||
|
category: "external",
|
||||||
|
color: "#06B6D4", // 시안
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
@ -131,7 +159,12 @@ export const NODE_CATEGORIES = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "action",
|
id: "action",
|
||||||
label: "액션",
|
label: "데이터 액션",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "external",
|
||||||
|
label: "외부 연동",
|
||||||
icon: "",
|
icon: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,83 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 컴포넌트 (폼 내부용)
|
||||||
|
*/
|
||||||
|
interface CascadingDropdownInFormProps {
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { options, loading } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={(newValue) => onChange?.(newValue)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={className}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
|
|
@ -1434,6 +1511,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
|
||||||
|
if (cascadingConfig?.enabled) {
|
||||||
|
const parentValue = editFormData[cascadingConfig.parentField];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CascadingDropdownInForm
|
||||||
|
config={cascadingConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={commonProps.placeholder}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const options = detailSettings?.options || [];
|
const options = detailSettings?.options || [];
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
|
|
@ -1670,9 +1766,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
|
||||||
|
if (cascadingConfigAdd?.enabled) {
|
||||||
|
const parentValueAdd = addFormData[cascadingConfigAdd.parentField];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CascadingDropdownInForm
|
||||||
|
config={cascadingConfigAdd}
|
||||||
|
parentValue={parentValueAdd}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={commonProps.placeholder}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const options = detailSettings?.options || [];
|
const optionsAdd = detailSettings?.options || [];
|
||||||
if (options.length > 0) {
|
if (optionsAdd.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||||
|
|
@ -1680,7 +1795,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<SelectValue placeholder={commonProps.placeholder} />
|
<SelectValue placeholder={commonProps.placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.map((option: any, index: number) => (
|
{optionsAdd.map((option: any, index: number) => (
|
||||||
<SelectItem key={index} value={option.value || option}>
|
<SelectItem key={index} value={option.value || option}>
|
||||||
{option.label || option}
|
{option.label || option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1696,20 +1811,20 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "radio":
|
case "radio":
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const radioOptions = detailSettings?.options || [];
|
const radioOptionsAdd = detailSettings?.options || [];
|
||||||
const defaultValue = detailSettings?.defaultValue;
|
const defaultValueAdd = detailSettings?.defaultValue;
|
||||||
|
|
||||||
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptionsAdd.length > 0) {
|
||||||
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
|
||||||
if (!value && defaultValue) {
|
if (!value && defaultValueAdd) {
|
||||||
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
|
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{radioOptions.map((option: any, index: number) => (
|
{radioOptionsAdd.map((option: any, index: number) => (
|
||||||
<div key={index} className="flex items-center space-x-2">
|
<div key={index} className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
import { CalendarIcon, File, Upload, X, Loader2 } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
import { ko } from "date-fns/locale";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
|
@ -49,6 +51,96 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||||
|
* InteractiveScreenViewer 내에서 사용
|
||||||
|
*/
|
||||||
|
interface CascadingDropdownWrapperProps {
|
||||||
|
/** 직접 설정 방식 */
|
||||||
|
config?: CascadingDropdownConfig;
|
||||||
|
/** 공통 관리 방식 (관계 코드) */
|
||||||
|
relationCode?: string;
|
||||||
|
/** 부모 필드명 (relationCode 사용 시 필요) */
|
||||||
|
parentFieldName?: string;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
|
||||||
|
config,
|
||||||
|
relationCode,
|
||||||
|
parentFieldName,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
}) => {
|
||||||
|
const { options, loading, error, relationConfig } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
relationCode,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
|
||||||
|
const effectiveConfig = config || relationConfig;
|
||||||
|
|
||||||
|
// 부모 값이 없을 때 메시지
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return effectiveConfig?.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return effectiveConfig?.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return effectiveConfig?.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={(newValue) => onChange?.(newValue)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-full w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
allComponents: ComponentData[];
|
allComponents: ComponentData[];
|
||||||
|
|
@ -697,10 +789,55 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
searchable: config?.searchable,
|
searchable: config?.searchable,
|
||||||
placeholder: config?.placeholder,
|
placeholder: config?.placeholder,
|
||||||
defaultValue: config?.defaultValue,
|
defaultValue: config?.defaultValue,
|
||||||
|
cascading: config?.cascading,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
|
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
|
||||||
|
if (config?.cascadingRelationCode && config?.cascadingParentField) {
|
||||||
|
const parentFieldValue = formData[config.cascadingParentField];
|
||||||
|
|
||||||
|
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
|
||||||
|
relationCode: config.cascadingRelationCode,
|
||||||
|
parentField: config.cascadingParentField,
|
||||||
|
parentValue: parentFieldValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return applyStyles(
|
||||||
|
<CascadingDropdownWrapper
|
||||||
|
relationCode={config.cascadingRelationCode}
|
||||||
|
parentFieldName={config.cascadingParentField}
|
||||||
|
parentValue={parentFieldValue}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
|
placeholder={finalPlaceholder}
|
||||||
|
disabled={readonly}
|
||||||
|
required={required}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
|
||||||
|
if (config?.cascading?.enabled) {
|
||||||
|
const cascadingConfig = config.cascading;
|
||||||
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
||||||
|
return applyStyles(
|
||||||
|
<CascadingDropdownWrapper
|
||||||
|
config={cascadingConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(value) => updateFormData(fieldName, value)}
|
||||||
|
placeholder={finalPlaceholder}
|
||||||
|
disabled={readonly}
|
||||||
|
required={required}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 Select
|
||||||
const options = config?.options || [
|
const options = config?.options || [
|
||||||
{ label: "옵션 1", value: "option1" },
|
{ label: "옵션 1", value: "option1" },
|
||||||
{ label: "옵션 2", value: "option2" },
|
{ label: "옵션 2", value: "option2" },
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,9 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Palette, Type, Square, Box } from "lucide-react";
|
import { Palette, Type, Square } from "lucide-react";
|
||||||
import { ComponentStyle } from "@/types/screen";
|
import { ComponentStyle } from "@/types/screen";
|
||||||
|
import { ColorPickerWithTransparent } from "./common/ColorPickerWithTransparent";
|
||||||
interface StyleEditorProps {
|
|
||||||
style: ComponentStyle;
|
|
||||||
onStyleChange: (style: ComponentStyle) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
|
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
|
||||||
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
|
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
|
||||||
|
|
@ -80,28 +75,18 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="borderColor" className="text-xs font-medium">
|
<Label htmlFor="borderColor" className="text-xs font-medium">
|
||||||
색상
|
색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-1">
|
<ColorPickerWithTransparent
|
||||||
<Input
|
|
||||||
id="borderColor"
|
id="borderColor"
|
||||||
type="color"
|
value={localStyle.borderColor}
|
||||||
value={localStyle.borderColor || "#000000"}
|
onChange={(value) => handleStyleChange("borderColor", value)}
|
||||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
defaultColor="#e5e7eb"
|
||||||
className="h-6 w-12 p-1"
|
placeholder="#e5e7eb"
|
||||||
className="text-xs"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={localStyle.borderColor || "#000000"}
|
|
||||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
|
||||||
placeholder="#000000"
|
|
||||||
className="h-6 flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
||||||
|
|
@ -132,24 +117,14 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
||||||
색상
|
색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-1">
|
<ColorPickerWithTransparent
|
||||||
<Input
|
|
||||||
id="backgroundColor"
|
id="backgroundColor"
|
||||||
type="color"
|
value={localStyle.backgroundColor}
|
||||||
value={localStyle.backgroundColor || "#ffffff"}
|
onChange={(value) => handleStyleChange("backgroundColor", value)}
|
||||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
defaultColor="#ffffff"
|
||||||
className="h-6 w-12 p-1"
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={localStyle.backgroundColor || "#ffffff"}
|
|
||||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
|
||||||
placeholder="#ffffff"
|
placeholder="#ffffff"
|
||||||
className="h-6 flex-1 text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||||
|
|
@ -178,29 +153,19 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-1.5" />
|
<Separator className="my-1.5" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="color" className="text-xs font-medium">
|
<Label htmlFor="color" className="text-xs font-medium">
|
||||||
색상
|
색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-1">
|
<ColorPickerWithTransparent
|
||||||
<Input
|
|
||||||
id="color"
|
id="color"
|
||||||
type="color"
|
value={localStyle.color}
|
||||||
value={localStyle.color || "#000000"}
|
onChange={(value) => handleStyleChange("color", value)}
|
||||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
defaultColor="#000000"
|
||||||
className="h-6 w-12 p-1"
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={localStyle.color || "#000000"}
|
|
||||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
className="h-6 flex-1 text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="fontSize" className="text-xs font-medium">
|
<Label htmlFor="fontSize" className="text-xs font-medium">
|
||||||
크기
|
크기
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ColorPickerWithTransparentProps {
|
||||||
|
id?: string;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
defaultColor?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 투명 옵션이 포함된 색상 선택 컴포넌트
|
||||||
|
* - 색상 선택기 (color input)
|
||||||
|
* - 텍스트 입력 (HEX 값 직접 입력)
|
||||||
|
* - 투명 버튼 (투명/색상 토글)
|
||||||
|
*/
|
||||||
|
export function ColorPickerWithTransparent({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
defaultColor = "#000000",
|
||||||
|
placeholder = "#000000",
|
||||||
|
className = "",
|
||||||
|
}: ColorPickerWithTransparentProps) {
|
||||||
|
const isTransparent = value === "transparent" || value === "";
|
||||||
|
const displayValue = isTransparent ? defaultColor : (value || defaultColor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-1 items-center ${className}`}>
|
||||||
|
{/* 색상 선택기 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="color"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-6 w-12 p-1 text-xs cursor-pointer"
|
||||||
|
disabled={isTransparent}
|
||||||
|
/>
|
||||||
|
{/* 투명 표시 오버레이 (체커보드 패턴) */}
|
||||||
|
{isTransparent && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center border rounded pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
|
||||||
|
backgroundSize: "8px 8px",
|
||||||
|
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] font-bold text-gray-600 bg-white/80 px-0.5 rounded">투명</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 텍스트 입력 */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={isTransparent ? "transparent" : (value || "")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (newValue === "transparent" || newValue === "") {
|
||||||
|
onChange("transparent");
|
||||||
|
} else {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 투명 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isTransparent ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (isTransparent) {
|
||||||
|
onChange(defaultColor);
|
||||||
|
} else {
|
||||||
|
onChange("transparent");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={isTransparent ? "색상 선택" : "투명으로 설정"}
|
||||||
|
>
|
||||||
|
{isTransparent ? "색상" : "투명"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColorPickerWithTransparent;
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
interface CardConfigPanelProps {
|
interface CardConfigPanelProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -93,11 +94,12 @@ export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({ component, onU
|
||||||
{/* 배경색 */}
|
{/* 배경색 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="background-color">배경색</Label>
|
<Label htmlFor="background-color">배경색</Label>
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
id="background-color"
|
id="background-color"
|
||||||
type="color"
|
value={config.backgroundColor}
|
||||||
value={config.backgroundColor || "#ffffff"}
|
onChange={(value) => handleConfigChange("backgroundColor", value)}
|
||||||
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
|
defaultColor="#ffffff"
|
||||||
|
placeholder="#ffffff"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
interface DashboardConfigPanelProps {
|
interface DashboardConfigPanelProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -124,11 +125,12 @@ export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({ comp
|
||||||
{/* 배경색 */}
|
{/* 배경색 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="background-color">배경색</Label>
|
<Label htmlFor="background-color">배경색</Label>
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
id="background-color"
|
id="background-color"
|
||||||
type="color"
|
value={config.backgroundColor}
|
||||||
value={config.backgroundColor || "#f8f9fa"}
|
onChange={(value) => handleConfigChange("backgroundColor", value)}
|
||||||
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
|
defaultColor="#f8f9fa"
|
||||||
|
placeholder="#f8f9fa"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
interface ProgressBarConfigPanelProps {
|
interface ProgressBarConfigPanelProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -52,11 +53,12 @@ export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="progress-color">진행률 색상</Label>
|
<Label htmlFor="progress-color">진행률 색상</Label>
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
id="progress-color"
|
id="progress-color"
|
||||||
type="color"
|
value={config.color}
|
||||||
value={config.color || "#3b82f6"}
|
onChange={(value) => onUpdateProperty("componentConfig.color", value)}
|
||||||
onChange={(e) => onUpdateProperty("componentConfig.color", e.target.value)}
|
defaultColor="#3b82f6"
|
||||||
|
placeholder="#3b82f6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, Trash2, ChevronDown, List } from "lucide-react";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, List, Link2, ExternalLink } from "lucide-react";
|
||||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||||
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
|
||||||
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -38,8 +41,19 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
required: config.required || false,
|
required: config.required || false,
|
||||||
readonly: config.readonly || false,
|
readonly: config.readonly || false,
|
||||||
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
|
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||||
|
cascadingRelationCode: config.cascadingRelationCode,
|
||||||
|
cascadingParentField: config.cascadingParentField,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 설정 상태
|
||||||
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
|
const [selectedRelationCode, setSelectedRelationCode] = useState(config.cascadingRelationCode || "");
|
||||||
|
const [selectedParentField, setSelectedParentField] = useState(config.cascadingParentField || "");
|
||||||
|
|
||||||
|
// 연쇄 관계 목록
|
||||||
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
// 새 옵션 추가용 상태
|
// 새 옵션 추가용 상태
|
||||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||||
const [newOptionValue, setNewOptionValue] = useState("");
|
const [newOptionValue, setNewOptionValue] = useState("");
|
||||||
|
|
@ -66,6 +80,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
required: currentConfig.required || false,
|
required: currentConfig.required || false,
|
||||||
readonly: currentConfig.readonly || false,
|
readonly: currentConfig.readonly || false,
|
||||||
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||||
|
cascadingRelationCode: currentConfig.cascadingRelationCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 입력 필드 로컬 상태도 동기화
|
// 입력 필드 로컬 상태도 동기화
|
||||||
|
|
@ -73,8 +88,35 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
placeholder: currentConfig.placeholder || "",
|
placeholder: currentConfig.placeholder || "",
|
||||||
emptyMessage: currentConfig.emptyMessage || "",
|
emptyMessage: currentConfig.emptyMessage || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 설정 동기화
|
||||||
|
setCascadingEnabled(!!currentConfig.cascadingRelationCode);
|
||||||
|
setSelectedRelationCode(currentConfig.cascadingRelationCode || "");
|
||||||
|
setSelectedParentField(currentConfig.cascadingParentField || "");
|
||||||
}, [widget.webTypeConfig]);
|
}, [widget.webTypeConfig]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (cascadingEnabled && relationList.length === 0) {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드 함수
|
||||||
|
const loadRelationList = async () => {
|
||||||
|
setLoadingRelations(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelationList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 설정 업데이트 핸들러
|
// 설정 업데이트 핸들러
|
||||||
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
|
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
|
||||||
const newConfig = { ...localConfig, [field]: value };
|
const newConfig = { ...localConfig, [field]: value };
|
||||||
|
|
@ -82,6 +124,38 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
onUpdateProperty("webTypeConfig", newConfig);
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 활성화/비활성화
|
||||||
|
const handleCascadingToggle = (enabled: boolean) => {
|
||||||
|
setCascadingEnabled(enabled);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 비활성화 시 관계 코드 제거
|
||||||
|
setSelectedRelationCode("");
|
||||||
|
const newConfig = { ...localConfig, cascadingRelationCode: undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
} else {
|
||||||
|
// 활성화 시 관계 목록 로드
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연쇄 관계 선택
|
||||||
|
const handleRelationSelect = (code: string) => {
|
||||||
|
setSelectedRelationCode(code);
|
||||||
|
const newConfig = { ...localConfig, cascadingRelationCode: code || undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 필드 선택
|
||||||
|
const handleParentFieldChange = (field: string) => {
|
||||||
|
setSelectedParentField(field);
|
||||||
|
const newConfig = { ...localConfig, cascadingParentField: field || undefined };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
// 옵션 추가
|
// 옵션 추가
|
||||||
const addOption = () => {
|
const addOption = () => {
|
||||||
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
|
||||||
|
|
@ -167,6 +241,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
updateConfig("options", defaultOptionSets[setName]);
|
updateConfig("options", defaultOptionSets[setName]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 선택된 관계 정보
|
||||||
|
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -238,7 +315,104 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본 옵션 세트 */}
|
{/* 연쇄 드롭다운 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
<h4 className="text-sm font-medium">기본 옵션 세트</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
@ -253,8 +427,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 옵션 관리 */}
|
{/* 옵션 관리 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">옵션 관리</h4>
|
<h4 className="text-sm font-medium">옵션 관리</h4>
|
||||||
|
|
||||||
|
|
@ -337,8 +513,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 기본값 설정 */}
|
{/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">기본값</h4>
|
<h4 className="text-sm font-medium">기본값</h4>
|
||||||
|
|
||||||
|
|
@ -361,6 +539,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 상태 설정 */}
|
{/* 상태 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -395,7 +574,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">미리보기</h4>
|
<h4 className="text-sm font-medium">미리보기</h4>
|
||||||
<div className="bg-muted/50 rounded-md border p-3">
|
<div className="bg-muted/50 rounded-md border p-3">
|
||||||
|
|
@ -422,11 +602,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SelectConfigPanel.displayName = "SelectConfigPanel";
|
SelectConfigPanel.displayName = "SelectConfigPanel";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1179,6 +1179,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
return currentTable?.columns || [];
|
return currentTable?.columns || [];
|
||||||
})()}
|
})()}
|
||||||
tables={tables} // 전체 테이블 목록 전달
|
tables={tables} // 전체 테이블 목록 전달
|
||||||
|
allComponents={components} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
|
||||||
import { GridSettings, ScreenResolution } from "@/types/screen";
|
import { GridSettings, ScreenResolution } from "@/types/screen";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
interface GridPanelProps {
|
interface GridPanelProps {
|
||||||
gridSettings: GridSettings;
|
gridSettings: GridSettings;
|
||||||
|
|
@ -105,20 +106,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
||||||
<Label htmlFor="gridColor" className="text-xs font-medium">
|
<Label htmlFor="gridColor" className="text-xs font-medium">
|
||||||
격자 색상
|
격자 색상
|
||||||
</Label>
|
</Label>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1">
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
id="gridColor"
|
id="gridColor"
|
||||||
type="color"
|
value={gridSettings.gridColor}
|
||||||
value={gridSettings.gridColor || "#d1d5db"}
|
onChange={(value) => updateSetting("gridColor", value)}
|
||||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
defaultColor="#d1d5db"
|
||||||
className="h-8 w-12 rounded border p-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={gridSettings.gridColor || "#d1d5db"}
|
|
||||||
onChange={(e) => updateSetting("gridColor", e.target.value)}
|
|
||||||
placeholder="#d1d5db"
|
placeholder="#d1d5db"
|
||||||
className="flex-1 text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
getBaseInputType,
|
getBaseInputType,
|
||||||
getDefaultDetailType,
|
getDefaultDetailType,
|
||||||
} from "@/types/input-type-mapping";
|
} from "@/types/input-type-mapping";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
|
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
|
||||||
const DataTableConfigPanelWrapper: React.FC<{
|
const DataTableConfigPanelWrapper: React.FC<{
|
||||||
|
|
@ -1092,18 +1093,19 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
<Label htmlFor="labelColor" className="text-sm font-medium">
|
<Label htmlFor="labelColor" className="text-sm font-medium">
|
||||||
색상
|
색상
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="mt-1">
|
||||||
|
<ColorPickerWithTransparent
|
||||||
id="labelColor"
|
id="labelColor"
|
||||||
type="color"
|
|
||||||
value={localInputs.labelColor}
|
value={localInputs.labelColor}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
const newValue = e.target.value;
|
setLocalInputs((prev) => ({ ...prev, labelColor: value || "" }));
|
||||||
setLocalInputs((prev) => ({ ...prev, labelColor: newValue }));
|
onUpdateProperty("style.labelColor", value);
|
||||||
onUpdateProperty("style.labelColor", newValue);
|
|
||||||
}}
|
}}
|
||||||
className="mt-1 h-8"
|
defaultColor="#212121"
|
||||||
|
placeholder="#212121"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { LayoutRow } from "@/types/grid-system";
|
import { LayoutRow } from "@/types/grid-system";
|
||||||
import { GapPreset, GAP_PRESETS } from "@/lib/constants/columnSpans";
|
import { GapPreset, GAP_PRESETS } from "@/lib/constants/columnSpans";
|
||||||
import { Rows, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter } from "lucide-react";
|
import { Rows, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter } from "lucide-react";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
|
|
||||||
interface RowSettingsPanelProps {
|
interface RowSettingsPanelProps {
|
||||||
row: LayoutRow;
|
row: LayoutRow;
|
||||||
|
|
@ -224,26 +225,12 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
|
||||||
{/* 배경색 */}
|
{/* 배경색 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">배경색</Label>
|
<Label className="text-sm font-medium">배경색</Label>
|
||||||
<div className="flex gap-2">
|
<ColorPickerWithTransparent
|
||||||
<Input
|
value={row.backgroundColor}
|
||||||
type="color"
|
onChange={(value) => onUpdateRow({ backgroundColor: value })}
|
||||||
value={row.backgroundColor || "#ffffff"}
|
defaultColor="#ffffff"
|
||||||
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
|
|
||||||
className="h-10 w-20 cursor-pointer p-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={row.backgroundColor || ""}
|
|
||||||
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
|
|
||||||
placeholder="#ffffff"
|
placeholder="#ffffff"
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
{row.backgroundColor && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => onUpdateRow({ backgroundColor: undefined })}>
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import {
|
||||||
import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||||||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||||
|
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
|
||||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||||
|
|
||||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||||
|
|
@ -365,6 +366,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -603,13 +606,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
|
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">커스텀 색상</Label>
|
<Label className="text-xs">커스텀 색상</Label>
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
type="color"
|
value={selectedComponent.componentConfig?.customColor}
|
||||||
value={selectedComponent.componentConfig?.customColor || "#f0f0f0"}
|
onChange={(value) => {
|
||||||
onChange={(e) => {
|
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", value);
|
||||||
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", e.target.value);
|
|
||||||
}}
|
}}
|
||||||
className="h-9"
|
defaultColor="#f0f0f0"
|
||||||
|
placeholder="#f0f0f0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -882,12 +885,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">색상</Label>
|
<Label className="text-xs">색상</Label>
|
||||||
<Input
|
<ColorPickerWithTransparent
|
||||||
type="color"
|
value={selectedComponent.style?.labelColor}
|
||||||
value={selectedComponent.style?.labelColor || "#212121"}
|
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||||
onChange={(e) => handleUpdate("style.labelColor", e.target.value)}
|
defaultColor="#212121"
|
||||||
className="h-6 w-full px-2 py-0 text-xs"
|
placeholder="#212121"
|
||||||
className="text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1074,6 +1076,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
Object.entries(newConfig).forEach(([key, value]) => {
|
Object.entries(newConfig).forEach(([key, value]) => {
|
||||||
|
|
@ -1237,6 +1241,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
tableColumns={currentTable?.columns || []}
|
tableColumns={currentTable?.columns || []}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||||
|
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||||
// 전체 componentConfig를 업데이트
|
// 전체 componentConfig를 업데이트
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,23 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
data-slot="command"
|
data-slot="command"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
|
|
@ -37,10 +28,10 @@ function CommandDialog({
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
|
|
@ -48,127 +39,92 @@ function CommandDialog({
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
|
||||||
className={cn("overflow-hidden p-0", className)}
|
|
||||||
showCloseButton={showCloseButton}
|
|
||||||
>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||||
data-slot="command-input-wrapper"
|
|
||||||
className="flex h-9 items-center gap-2 border-b px-3"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandList({
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain", className)}
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
onWheel={(e) => {
|
||||||
className
|
e.stopPropagation();
|
||||||
)}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
...props
|
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
|
||||||
return (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
data-slot="command-empty"
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandGroup({
|
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandSeparator({
|
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator
|
||||||
data-slot="command-separator"
|
data-slot="command-separator"
|
||||||
className={cn("bg-border -mx-1 h-px", className)}
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandItem({
|
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
data-slot="command-item"
|
data-slot="command-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandShortcut({
|
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
className={cn(
|
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -181,4 +137,4 @@ export {
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 커스텀 훅
|
||||||
|
* 마스터 선택 시 여러 필드를 자동으로 입력하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
cascadingAutoFillApi,
|
||||||
|
AutoFillGroup,
|
||||||
|
AutoFillOption,
|
||||||
|
} from "@/lib/api/cascadingAutoFill";
|
||||||
|
|
||||||
|
interface AutoFillMapping {
|
||||||
|
targetField: string;
|
||||||
|
targetLabel: string;
|
||||||
|
value: any;
|
||||||
|
isEditable: boolean;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoFillProps {
|
||||||
|
/** 자동 입력 그룹 코드 */
|
||||||
|
groupCode: string;
|
||||||
|
/** 자동 입력 데이터가 로드되었을 때 호출되는 콜백 */
|
||||||
|
onAutoFill?: (data: Record<string, any>, mappings: AutoFillMapping[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoFillResult {
|
||||||
|
/** 마스터 옵션 목록 */
|
||||||
|
masterOptions: AutoFillOption[];
|
||||||
|
/** 현재 선택된 마스터 값 */
|
||||||
|
selectedMasterValue: string | null;
|
||||||
|
/** 자동 입력된 데이터 */
|
||||||
|
autoFilledData: Record<string, any>;
|
||||||
|
/** 매핑 정보 */
|
||||||
|
mappings: AutoFillMapping[];
|
||||||
|
/** 그룹 정보 */
|
||||||
|
groupInfo: AutoFillGroup | null;
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** 에러 메시지 */
|
||||||
|
error: string | null;
|
||||||
|
/** 마스터 값 선택 핸들러 */
|
||||||
|
selectMasterValue: (value: string) => Promise<void>;
|
||||||
|
/** 마스터 옵션 새로고침 */
|
||||||
|
refreshOptions: () => Promise<void>;
|
||||||
|
/** 자동 입력 데이터 초기화 */
|
||||||
|
clearAutoFill: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoFill({
|
||||||
|
groupCode,
|
||||||
|
onAutoFill,
|
||||||
|
}: UseAutoFillProps): UseAutoFillResult {
|
||||||
|
// 상태
|
||||||
|
const [masterOptions, setMasterOptions] = useState<AutoFillOption[]>([]);
|
||||||
|
const [selectedMasterValue, setSelectedMasterValue] = useState<string | null>(null);
|
||||||
|
const [autoFilledData, setAutoFilledData] = useState<Record<string, any>>({});
|
||||||
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||||
|
const [groupInfo, setGroupInfo] = useState<AutoFillGroup | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 마스터 옵션 로드
|
||||||
|
const loadMasterOptions = useCallback(async () => {
|
||||||
|
if (!groupCode) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 그룹 정보 로드
|
||||||
|
const groupResponse = await cascadingAutoFillApi.getGroupDetail(groupCode);
|
||||||
|
if (groupResponse.success && groupResponse.data) {
|
||||||
|
setGroupInfo(groupResponse.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 옵션 로드
|
||||||
|
const optionsResponse = await cascadingAutoFillApi.getMasterOptions(groupCode);
|
||||||
|
if (optionsResponse.success && optionsResponse.data) {
|
||||||
|
setMasterOptions(optionsResponse.data);
|
||||||
|
} else {
|
||||||
|
setError(optionsResponse.error || "옵션 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "옵션 로드 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupCode]);
|
||||||
|
|
||||||
|
// 마스터 값 선택 시 자동 입력 데이터 로드
|
||||||
|
const selectMasterValue = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
if (!groupCode || !value) {
|
||||||
|
setSelectedMasterValue(null);
|
||||||
|
setAutoFilledData({});
|
||||||
|
setMappings([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSelectedMasterValue(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.getData(groupCode, value);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const data = response.data || {};
|
||||||
|
const mappingInfo = response.mappings || [];
|
||||||
|
|
||||||
|
setAutoFilledData(data);
|
||||||
|
setMappings(mappingInfo);
|
||||||
|
|
||||||
|
// 콜백 호출
|
||||||
|
if (onAutoFill) {
|
||||||
|
onAutoFill(data, mappingInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(response.error || "데이터 로드 실패");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "데이터 로드 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[groupCode, onAutoFill]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 자동 입력 데이터 초기화
|
||||||
|
const clearAutoFill = useCallback(() => {
|
||||||
|
setSelectedMasterValue(null);
|
||||||
|
setAutoFilledData({});
|
||||||
|
setMappings([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupCode) {
|
||||||
|
loadMasterOptions();
|
||||||
|
}
|
||||||
|
}, [groupCode, loadMasterOptions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterOptions,
|
||||||
|
selectedMasterValue,
|
||||||
|
autoFilledData,
|
||||||
|
mappings,
|
||||||
|
groupInfo,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
selectMasterValue,
|
||||||
|
refreshOptions: loadMasterOptions,
|
||||||
|
clearAutoFill,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 화면관리 시스템용 자동 입력 컴포넌트 설정 타입
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface AutoFillConfig {
|
||||||
|
/** 자동 입력 활성화 여부 */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 자동 입력 그룹 코드 */
|
||||||
|
groupCode: string;
|
||||||
|
/** 마스터 필드명 (이 필드 선택 시 자동 입력 트리거) */
|
||||||
|
masterField: string;
|
||||||
|
/** 자동 입력 후 수정 가능 여부 (전체 설정) */
|
||||||
|
allowEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 데이터에 자동 입력 적용
|
||||||
|
*/
|
||||||
|
export function applyAutoFillToFormData(
|
||||||
|
formData: Record<string, any>,
|
||||||
|
autoFilledData: Record<string, any>,
|
||||||
|
mappings: AutoFillMapping[]
|
||||||
|
): Record<string, any> {
|
||||||
|
const result = { ...formData };
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
// 수정 불가능한 필드이거나 기존 값이 없는 경우에만 자동 입력
|
||||||
|
if (!mapping.isEditable || !result[mapping.targetField]) {
|
||||||
|
result[mapping.targetField] = autoFilledData[mapping.targetField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,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,231 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface AutoFillMapping {
|
||||||
|
mappingId?: number;
|
||||||
|
sourceColumn: string;
|
||||||
|
targetField: string;
|
||||||
|
targetLabel?: string;
|
||||||
|
isEditable?: string;
|
||||||
|
isRequired?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillGroup {
|
||||||
|
groupId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
masterTable: string;
|
||||||
|
masterValueColumn: string;
|
||||||
|
masterLabelColumn?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
mappingCount?: number;
|
||||||
|
mappings?: AutoFillMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoFillDataResponse {
|
||||||
|
data: Record<string, any>;
|
||||||
|
mappings: Array<{
|
||||||
|
targetField: string;
|
||||||
|
targetLabel: string;
|
||||||
|
value: any;
|
||||||
|
isEditable: boolean;
|
||||||
|
isRequired: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillGroups(isActive?: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isActive) params.append("isActive", isActive);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/groups?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
|
*/
|
||||||
|
export async function getAutoFillGroupDetail(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/groups/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 생성
|
||||||
|
*/
|
||||||
|
export async function createAutoFillGroup(data: {
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
masterTable: string;
|
||||||
|
masterValueColumn: string;
|
||||||
|
masterLabelColumn?: string;
|
||||||
|
mappings?: AutoFillMapping[];
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-auto-fill/groups", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 수정
|
||||||
|
*/
|
||||||
|
export async function updateAutoFillGroup(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<AutoFillGroup> & { mappings?: AutoFillMapping[] }
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-auto-fill/groups/${groupCode}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteAutoFillGroup(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-auto-fill/groups/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 그룹 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 옵션 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillMasterOptions(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: AutoFillOption[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-auto-fill/options/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("마스터 옵션 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 데이터 조회
|
||||||
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
|
*/
|
||||||
|
export async function getAutoFillData(
|
||||||
|
groupCode: string,
|
||||||
|
masterValue: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
mappings?: AutoFillDataResponse["mappings"];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-auto-fill/data/${groupCode}?masterValue=${encodeURIComponent(masterValue)}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("자동 입력 데이터 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const cascadingAutoFillApi = {
|
||||||
|
getGroups: getAutoFillGroups,
|
||||||
|
getGroupDetail: getAutoFillGroupDetail,
|
||||||
|
createGroup: createAutoFillGroup,
|
||||||
|
updateGroup: updateAutoFillGroup,
|
||||||
|
deleteGroup: deleteAutoFillGroup,
|
||||||
|
getMasterOptions: getAutoFillMasterOptions,
|
||||||
|
getData: getAutoFillData,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface CascadingCondition {
|
||||||
|
conditionId?: number;
|
||||||
|
relationType: string; // "RELATION" | "HIERARCHY"
|
||||||
|
relationCode: string;
|
||||||
|
conditionName: string;
|
||||||
|
conditionField: string;
|
||||||
|
conditionOperator: string; // "EQ" | "NEQ" | "CONTAINS" | "IN" | "GT" | "LT" 등
|
||||||
|
conditionValue: string;
|
||||||
|
filterColumn: string;
|
||||||
|
filterValues: string; // 콤마로 구분된 값들
|
||||||
|
priority?: number;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연산자 목록
|
||||||
|
export const CONDITION_OPERATORS = [
|
||||||
|
{ value: "EQ", label: "같음 (=)" },
|
||||||
|
{ value: "NEQ", label: "같지 않음 (!=)" },
|
||||||
|
{ value: "CONTAINS", label: "포함" },
|
||||||
|
{ value: "NOT_CONTAINS", label: "포함하지 않음" },
|
||||||
|
{ value: "STARTS_WITH", label: "시작" },
|
||||||
|
{ value: "ENDS_WITH", label: "끝" },
|
||||||
|
{ value: "IN", label: "목록에 포함" },
|
||||||
|
{ value: "NOT_IN", label: "목록에 미포함" },
|
||||||
|
{ value: "GT", label: "보다 큼 (>)" },
|
||||||
|
{ value: "GTE", label: "보다 크거나 같음 (>=)" },
|
||||||
|
{ value: "LT", label: "보다 작음 (<)" },
|
||||||
|
{ value: "LTE", label: "보다 작거나 같음 (<=)" },
|
||||||
|
{ value: "IS_NULL", label: "비어있음" },
|
||||||
|
{ value: "IS_NOT_NULL", label: "비어있지 않음" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getConditions(params?: {
|
||||||
|
isActive?: string;
|
||||||
|
relationCode?: string;
|
||||||
|
relationType?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.isActive) searchParams.append("isActive", params.isActive);
|
||||||
|
if (params?.relationCode) searchParams.append("relationCode", params.relationCode);
|
||||||
|
if (params?.relationType) searchParams.append("relationType", params.relationType);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-conditions?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getConditionDetail(conditionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-conditions/${conditionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 생성
|
||||||
|
*/
|
||||||
|
export async function createCondition(data: Omit<CascadingCondition, "conditionId">): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-conditions", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 수정
|
||||||
|
*/
|
||||||
|
export async function updateCondition(
|
||||||
|
conditionId: number,
|
||||||
|
data: Partial<CascadingCondition>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: CascadingCondition;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-conditions/${conditionId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteCondition(conditionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-conditions/${conditionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
|
*/
|
||||||
|
export async function getFilteredOptions(
|
||||||
|
relationCode: string,
|
||||||
|
params: {
|
||||||
|
conditionFieldValue?: string;
|
||||||
|
parentValue?: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
appliedCondition?: { conditionId: number; conditionName: string } | null;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.conditionFieldValue) searchParams.append("conditionFieldValue", params.conditionFieldValue);
|
||||||
|
if (params.parentValue) searchParams.append("parentValue", params.parentValue);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-conditions/filtered-options/${relationCode}?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 필터링 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const cascadingConditionApi = {
|
||||||
|
getList: getConditions,
|
||||||
|
getDetail: getConditionDetail,
|
||||||
|
create: createCondition,
|
||||||
|
update: updateCondition,
|
||||||
|
delete: deleteCondition,
|
||||||
|
getFilteredOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface HierarchyLevel {
|
||||||
|
levelId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
companyCode?: string;
|
||||||
|
levelOrder: number;
|
||||||
|
levelName: string;
|
||||||
|
levelCode?: string;
|
||||||
|
tableName: string;
|
||||||
|
valueColumn: string;
|
||||||
|
labelColumn: string;
|
||||||
|
parentKeyColumn?: string;
|
||||||
|
filterColumn?: string;
|
||||||
|
filterValue?: string;
|
||||||
|
orderColumn?: string;
|
||||||
|
orderDirection?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
isRequired?: string;
|
||||||
|
isSearchable?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyGroup {
|
||||||
|
groupId?: number;
|
||||||
|
groupCode: string;
|
||||||
|
groupName: string;
|
||||||
|
description?: string;
|
||||||
|
hierarchyType: "MULTI_TABLE" | "SELF_REFERENCE" | "BOM" | "TREE";
|
||||||
|
maxLevels?: number;
|
||||||
|
isFixedLevels?: string;
|
||||||
|
// Self-reference 설정
|
||||||
|
selfRefTable?: string;
|
||||||
|
selfRefIdColumn?: string;
|
||||||
|
selfRefParentColumn?: string;
|
||||||
|
selfRefValueColumn?: string;
|
||||||
|
selfRefLabelColumn?: string;
|
||||||
|
selfRefLevelColumn?: string;
|
||||||
|
selfRefOrderColumn?: string;
|
||||||
|
// BOM 설정
|
||||||
|
bomTable?: string;
|
||||||
|
bomParentColumn?: string;
|
||||||
|
bomChildColumn?: string;
|
||||||
|
bomItemTable?: string;
|
||||||
|
bomItemIdColumn?: string;
|
||||||
|
bomItemLabelColumn?: string;
|
||||||
|
bomQtyColumn?: string;
|
||||||
|
bomLevelColumn?: string;
|
||||||
|
// 메시지
|
||||||
|
emptyMessage?: string;
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
// 메타
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
updatedDate?: string;
|
||||||
|
// 조회 시 포함
|
||||||
|
levels?: HierarchyLevel[];
|
||||||
|
levelCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계층 타입
|
||||||
|
export const HIERARCHY_TYPES = [
|
||||||
|
{ value: "MULTI_TABLE", label: "다중 테이블 (국가>도시>구)" },
|
||||||
|
{ value: "SELF_REFERENCE", label: "자기 참조 (조직도)" },
|
||||||
|
{ value: "BOM", label: "BOM (부품 구조)" },
|
||||||
|
{ value: "TREE", label: "트리 (카테고리)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getHierarchyGroups(params?: {
|
||||||
|
isActive?: string;
|
||||||
|
hierarchyType?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.isActive) searchParams.append("isActive", params.isActive);
|
||||||
|
if (params?.hierarchyType) searchParams.append("hierarchyType", params.hierarchyType);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-hierarchy?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
|
*/
|
||||||
|
export async function getHierarchyGroupDetail(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-hierarchy/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 생성
|
||||||
|
*/
|
||||||
|
export async function createHierarchyGroup(
|
||||||
|
data: Omit<HierarchyGroup, "groupId"> & { levels?: Partial<HierarchyLevel>[] }
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-hierarchy", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 수정
|
||||||
|
*/
|
||||||
|
export async function updateHierarchyGroup(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<HierarchyGroup>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyGroup;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-hierarchy/${groupCode}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteHierarchyGroup(groupCode: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-hierarchy/${groupCode}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("계층 그룹 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 추가
|
||||||
|
*/
|
||||||
|
export async function addLevel(
|
||||||
|
groupCode: string,
|
||||||
|
data: Partial<HierarchyLevel>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyLevel;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/cascading-hierarchy/${groupCode}/levels`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 추가 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 수정
|
||||||
|
*/
|
||||||
|
export async function updateLevel(
|
||||||
|
levelId: number,
|
||||||
|
data: Partial<HierarchyLevel>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: HierarchyLevel;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-hierarchy/levels/${levelId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteLevel(levelId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-hierarchy/levels/${levelId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레벨의 옵션 조회
|
||||||
|
*/
|
||||||
|
export async function getLevelOptions(
|
||||||
|
groupCode: string,
|
||||||
|
levelOrder: number,
|
||||||
|
parentValue?: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
levelInfo?: {
|
||||||
|
levelId: number;
|
||||||
|
levelName: string;
|
||||||
|
placeholder: string;
|
||||||
|
isRequired: string;
|
||||||
|
isSearchable: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (parentValue) params.append("parentValue", parentValue);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-hierarchy/${groupCode}/options/${levelOrder}?${params.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("레벨 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const hierarchyApi = {
|
||||||
|
getGroups: getHierarchyGroups,
|
||||||
|
getDetail: getHierarchyGroupDetail,
|
||||||
|
createGroup: createHierarchyGroup,
|
||||||
|
updateGroup: updateHierarchyGroup,
|
||||||
|
deleteGroup: deleteHierarchyGroup,
|
||||||
|
addLevel,
|
||||||
|
updateLevel,
|
||||||
|
deleteLevel,
|
||||||
|
getLevelOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 타입 정의
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface MutualExclusion {
|
||||||
|
exclusionId?: number;
|
||||||
|
exclusionCode: string;
|
||||||
|
exclusionName: string;
|
||||||
|
fieldNames: string; // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||||
|
sourceTable: string;
|
||||||
|
valueColumn: string;
|
||||||
|
labelColumn?: string;
|
||||||
|
exclusionType?: string; // "SAME_VALUE"
|
||||||
|
errorMessage?: string;
|
||||||
|
companyCode?: string;
|
||||||
|
isActive?: string;
|
||||||
|
createdDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배제 타입 목록
|
||||||
|
export const EXCLUSION_TYPES = [
|
||||||
|
{ value: "SAME_VALUE", label: "동일 값 배제" },
|
||||||
|
{ value: "RELATED", label: "관련 값 배제 (예정)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API 함수
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getExclusions(isActive?: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isActive) params.append("isActive", isActive);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/cascading-exclusions?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getExclusionDetail(exclusionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/cascading-exclusions/${exclusionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 상세 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 생성
|
||||||
|
*/
|
||||||
|
export async function createExclusion(data: Omit<MutualExclusion, "exclusionId">): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/cascading-exclusions", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 생성 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 수정
|
||||||
|
*/
|
||||||
|
export async function updateExclusion(
|
||||||
|
exclusionId: number,
|
||||||
|
data: Partial<MutualExclusion>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MutualExclusion;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/cascading-exclusions/${exclusionId}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 수정 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteExclusion(exclusionId: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/cascading-exclusions/${exclusionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 규칙 삭제 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 검증
|
||||||
|
*/
|
||||||
|
export async function validateExclusion(
|
||||||
|
exclusionCode: string,
|
||||||
|
fieldValues: Record<string, string>
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
isValid: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
conflictingFields: string[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/cascading-exclusions/validate/${exclusionCode}`, {
|
||||||
|
fieldValues,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("상호 배제 검증 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배제된 옵션 조회 (다른 필드에서 선택한 값 제외)
|
||||||
|
*/
|
||||||
|
export async function getExcludedOptions(
|
||||||
|
exclusionCode: string,
|
||||||
|
params: {
|
||||||
|
currentField?: string;
|
||||||
|
selectedValues?: string; // 콤마로 구분된 값들
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Array<{ value: string; label: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.currentField) searchParams.append("currentField", params.currentField);
|
||||||
|
if (params.selectedValues) searchParams.append("selectedValues", params.selectedValues);
|
||||||
|
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/cascading-exclusions/options/${exclusionCode}?${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("배제된 옵션 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편의를 위한 네임스페이스 export
|
||||||
|
export const mutualExclusionApi = {
|
||||||
|
getList: getExclusions,
|
||||||
|
getDetail: getExclusionDetail,
|
||||||
|
create: createExclusion,
|
||||||
|
update: updateExclusion,
|
||||||
|
delete: deleteExclusion,
|
||||||
|
validate: validateExclusion,
|
||||||
|
getExcludedOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -60,6 +60,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
|
|
||||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||||
allComponents?: any[];
|
allComponents?: any[];
|
||||||
|
|
||||||
|
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
||||||
|
groupedData?: Record<string, any>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -98,6 +101,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
allComponents, // 🆕 같은 화면의 모든 컴포넌트
|
allComponents, // 🆕 같은 화면의 모든 컴포넌트
|
||||||
|
groupedData, // 🆕 부모창에서 전달된 그룹 데이터
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
|
|
@ -807,9 +811,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
// 🆕 선택된 데이터 우선순위:
|
||||||
|
// 1. selectedRowsData (테이블에서 직접 선택)
|
||||||
|
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
||||||
|
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
||||||
let effectiveSelectedRowsData = selectedRowsData;
|
let effectiveSelectedRowsData = selectedRowsData;
|
||||||
if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) {
|
|
||||||
|
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
||||||
|
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
|
||||||
|
effectiveSelectedRowsData = groupedData;
|
||||||
|
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
||||||
|
count: groupedData.length,
|
||||||
|
data: groupedData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||||
|
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
|
||||||
try {
|
try {
|
||||||
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
||||||
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생
|
||||||
// 🆕 세금계산서 관리 컴포넌트
|
// 🆕 세금계산서 관리 컴포넌트
|
||||||
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
|
||||||
|
|
||||||
|
// 🆕 메일 수신자 선택 컴포넌트
|
||||||
|
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 수신자 선택 컴포넌트
|
||||||
|
* InteractiveScreenViewer에서 사용하는 래퍼 컴포넌트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { X, Plus, Users, Mail, Check } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getUserList } from "@/lib/api/user";
|
||||||
|
import type {
|
||||||
|
MailRecipientSelectorConfig,
|
||||||
|
Recipient,
|
||||||
|
InternalUser,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface MailRecipientSelectorComponentProps {
|
||||||
|
// 컴포넌트 기본 Props
|
||||||
|
id?: string;
|
||||||
|
componentConfig?: MailRecipientSelectorConfig;
|
||||||
|
|
||||||
|
// 폼 데이터 연동
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 모드
|
||||||
|
isPreviewMode?: boolean;
|
||||||
|
isInteractive?: boolean;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
|
||||||
|
// 기타 Props (무시)
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MailRecipientSelectorComponent: React.FC<
|
||||||
|
MailRecipientSelectorComponentProps
|
||||||
|
> = ({
|
||||||
|
id,
|
||||||
|
componentConfig,
|
||||||
|
formData = {},
|
||||||
|
onFormDataChange,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
isPreviewMode = false,
|
||||||
|
isInteractive = true,
|
||||||
|
isDesignMode = false,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
// config 기본값
|
||||||
|
const config = componentConfig || {};
|
||||||
|
const {
|
||||||
|
toFieldName = "mailTo",
|
||||||
|
ccFieldName = "mailCc",
|
||||||
|
showCc = true,
|
||||||
|
showInternalSelector = true,
|
||||||
|
showExternalInput = true,
|
||||||
|
toLabel = "수신자",
|
||||||
|
ccLabel = "참조(CC)",
|
||||||
|
maxRecipients,
|
||||||
|
maxCcRecipients,
|
||||||
|
required = true,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
const [toRecipients, setToRecipients] = useState<Recipient[]>([]);
|
||||||
|
const [ccRecipients, setCcRecipients] = useState<Recipient[]>([]);
|
||||||
|
const [externalEmail, setExternalEmail] = useState("");
|
||||||
|
const [externalCcEmail, setExternalCcEmail] = useState("");
|
||||||
|
const [internalUsers, setInternalUsers] = useState<InternalUser[]>([]);
|
||||||
|
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
|
||||||
|
const [toPopoverOpen, setToPopoverOpen] = useState(false);
|
||||||
|
const [ccPopoverOpen, setCcPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
// 내부 사용자 목록 로드
|
||||||
|
const loadInternalUsers = useCallback(async () => {
|
||||||
|
if (!showInternalSelector || isDesignMode) return;
|
||||||
|
|
||||||
|
setIsLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const response = await getUserList({ status: "active", limit: 1000 });
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setInternalUsers(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("사용자 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingUsers(false);
|
||||||
|
}
|
||||||
|
}, [showInternalSelector, isDesignMode]);
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 사용자 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
loadInternalUsers();
|
||||||
|
}, [loadInternalUsers]);
|
||||||
|
|
||||||
|
// formData에서 초기값 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData[toFieldName]) {
|
||||||
|
const emails = formData[toFieldName].split(",").filter(Boolean);
|
||||||
|
const recipients: Recipient[] = emails.map((email: string) => ({
|
||||||
|
id: `external-${email.trim()}`,
|
||||||
|
email: email.trim(),
|
||||||
|
type: "external" as const,
|
||||||
|
}));
|
||||||
|
setToRecipients(recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData[ccFieldName]) {
|
||||||
|
const emails = formData[ccFieldName].split(",").filter(Boolean);
|
||||||
|
const recipients: Recipient[] = emails.map((email: string) => ({
|
||||||
|
id: `external-${email.trim()}`,
|
||||||
|
email: email.trim(),
|
||||||
|
type: "external" as const,
|
||||||
|
}));
|
||||||
|
setCcRecipients(recipients);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수신자 변경 시 formData 업데이트
|
||||||
|
const updateFormData = useCallback(
|
||||||
|
(recipients: Recipient[], fieldName: string) => {
|
||||||
|
const emailString = recipients.map((r) => r.email).join(",");
|
||||||
|
onFormDataChange?.(fieldName, emailString);
|
||||||
|
},
|
||||||
|
[onFormDataChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 수신자 추가 (내부 사용자)
|
||||||
|
const addInternalRecipient = useCallback(
|
||||||
|
(user: InternalUser, type: "to" | "cc") => {
|
||||||
|
const email = user.email || `${user.userId}@company.com`;
|
||||||
|
const newRecipient: Recipient = {
|
||||||
|
id: `internal-${user.userId}`,
|
||||||
|
email,
|
||||||
|
name: user.userName,
|
||||||
|
type: "internal",
|
||||||
|
userId: user.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "to") {
|
||||||
|
// 중복 체크
|
||||||
|
if (toRecipients.some((r) => r.email === email)) return;
|
||||||
|
// 최대 수신자 수 체크
|
||||||
|
if (maxRecipients && toRecipients.length >= maxRecipients) return;
|
||||||
|
|
||||||
|
const updated = [...toRecipients, newRecipient];
|
||||||
|
setToRecipients(updated);
|
||||||
|
updateFormData(updated, toFieldName);
|
||||||
|
setToPopoverOpen(false);
|
||||||
|
} else {
|
||||||
|
if (ccRecipients.some((r) => r.email === email)) return;
|
||||||
|
if (maxCcRecipients && ccRecipients.length >= maxCcRecipients) return;
|
||||||
|
|
||||||
|
const updated = [...ccRecipients, newRecipient];
|
||||||
|
setCcRecipients(updated);
|
||||||
|
updateFormData(updated, ccFieldName);
|
||||||
|
setCcPopoverOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
toRecipients,
|
||||||
|
ccRecipients,
|
||||||
|
maxRecipients,
|
||||||
|
maxCcRecipients,
|
||||||
|
toFieldName,
|
||||||
|
ccFieldName,
|
||||||
|
updateFormData,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 외부 이메일 추가
|
||||||
|
const addExternalEmail = useCallback(
|
||||||
|
(email: string, type: "to" | "cc") => {
|
||||||
|
// 이메일 형식 검증
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) return;
|
||||||
|
|
||||||
|
const newRecipient: Recipient = {
|
||||||
|
id: `external-${email}-${type}`,
|
||||||
|
email,
|
||||||
|
type: "external",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "to") {
|
||||||
|
// 해당 필드 내에서만 중복 체크
|
||||||
|
if (toRecipients.some((r) => r.email === email)) return;
|
||||||
|
if (maxRecipients && toRecipients.length >= maxRecipients) return;
|
||||||
|
|
||||||
|
const updated = [...toRecipients, newRecipient];
|
||||||
|
setToRecipients(updated);
|
||||||
|
updateFormData(updated, toFieldName);
|
||||||
|
setExternalEmail("");
|
||||||
|
} else {
|
||||||
|
// 해당 필드 내에서만 중복 체크
|
||||||
|
if (ccRecipients.some((r) => r.email === email)) return;
|
||||||
|
if (maxCcRecipients && ccRecipients.length >= maxCcRecipients) return;
|
||||||
|
|
||||||
|
const updated = [...ccRecipients, newRecipient];
|
||||||
|
setCcRecipients(updated);
|
||||||
|
updateFormData(updated, ccFieldName);
|
||||||
|
setExternalCcEmail("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
toRecipients,
|
||||||
|
ccRecipients,
|
||||||
|
maxRecipients,
|
||||||
|
maxCcRecipients,
|
||||||
|
toFieldName,
|
||||||
|
ccFieldName,
|
||||||
|
updateFormData,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 수신자 제거
|
||||||
|
const removeRecipient = useCallback(
|
||||||
|
(recipientId: string, type: "to" | "cc") => {
|
||||||
|
if (type === "to") {
|
||||||
|
const updated = toRecipients.filter((r) => r.id !== recipientId);
|
||||||
|
setToRecipients(updated);
|
||||||
|
updateFormData(updated, toFieldName);
|
||||||
|
} else {
|
||||||
|
const updated = ccRecipients.filter((r) => r.id !== recipientId);
|
||||||
|
setCcRecipients(updated);
|
||||||
|
updateFormData(updated, ccFieldName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toRecipients, ccRecipients, toFieldName, ccFieldName, updateFormData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 이미 선택된 사용자인지 확인 (해당 필드 내에서만 중복 체크)
|
||||||
|
const isUserSelected = useCallback(
|
||||||
|
(user: InternalUser, type: "to" | "cc") => {
|
||||||
|
const recipients = type === "to" ? toRecipients : ccRecipients;
|
||||||
|
const userEmail = user.email || `${user.userId}@company.com`;
|
||||||
|
return recipients.some((r) => r.email === userEmail);
|
||||||
|
},
|
||||||
|
[toRecipients, ccRecipients]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 수신자 태그 렌더링
|
||||||
|
const renderRecipientTags = (recipients: Recipient[], type: "to" | "cc") => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<Badge
|
||||||
|
key={recipient.id}
|
||||||
|
variant={recipient.type === "internal" ? "default" : "secondary"}
|
||||||
|
className="flex items-center gap-1 pr-1"
|
||||||
|
>
|
||||||
|
{recipient.type === "internal" ? (
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="max-w-[150px] truncate">
|
||||||
|
{recipient.name || recipient.email}
|
||||||
|
</span>
|
||||||
|
{isInteractive && !isDesignMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRecipient(recipient.id, type)}
|
||||||
|
className="ml-1 rounded-full p-0.5 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 내부 사용자 선택 팝오버
|
||||||
|
const renderInternalSelector = (type: "to" | "cc") => {
|
||||||
|
const isOpen = type === "to" ? toPopoverOpen : ccPopoverOpen;
|
||||||
|
const setIsOpen = type === "to" ? setToPopoverOpen : setCcPopoverOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!isInteractive || isLoadingUsers || isDesignMode}
|
||||||
|
>
|
||||||
|
<Users className="mr-1 h-4 w-4" />
|
||||||
|
내부 인원
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="이름 또는 이메일로 검색..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{internalUsers.map((user, index) => {
|
||||||
|
const userEmail = user.email || `${user.userId}@company.com`;
|
||||||
|
const selected = isUserSelected(user, type);
|
||||||
|
const uniqueKey = `${user.userId}-${index}`;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={uniqueKey}
|
||||||
|
value={`${user.userId}-${user.userName}-${userEmail}`}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!selected) {
|
||||||
|
addInternalRecipient(user, type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
selected && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{user.userName}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{userEmail}
|
||||||
|
{user.deptName && ` | ${user.deptName}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selected && <Check className="h-4 w-4 text-green-500" />}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 외부 이메일 입력
|
||||||
|
const renderExternalInput = (type: "to" | "cc") => {
|
||||||
|
const value = type === "to" ? externalEmail : externalCcEmail;
|
||||||
|
const setValue = type === "to" ? setExternalEmail : setExternalCcEmail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="외부 이메일 입력"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addExternalEmail(value, type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-sm"
|
||||||
|
disabled={!isInteractive || isDesignMode}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => addExternalEmail(value, type)}
|
||||||
|
disabled={!isInteractive || !value || isDesignMode}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 또는 프리뷰 모드
|
||||||
|
if (isDesignMode || isPreviewMode) {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3 rounded-md border p-3 bg-white", className)} style={style}>
|
||||||
|
<div className="text-sm font-medium text-gray-500">메일 수신자 선택</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>내부 인원 선택</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<span>외부 이메일 입력</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)} style={style}>
|
||||||
|
{/* 수신자 (To) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{toLabel}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* 선택된 수신자 태그 */}
|
||||||
|
{toRecipients.length > 0 && (
|
||||||
|
<div className="rounded-md border bg-gray-50 p-2">
|
||||||
|
{renderRecipientTags(toRecipients, "to")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 버튼들 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{showInternalSelector && renderInternalSelector("to")}
|
||||||
|
{showExternalInput && renderExternalInput("to")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참조 (CC) */}
|
||||||
|
{showCc && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{ccLabel}</Label>
|
||||||
|
|
||||||
|
{/* 선택된 참조 수신자 태그 */}
|
||||||
|
{ccRecipients.length > 0 && (
|
||||||
|
<div className="rounded-md border bg-gray-50 p-2">
|
||||||
|
{renderRecipientTags(ccRecipients, "cc")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 버튼들 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{showInternalSelector && renderInternalSelector("cc")}
|
||||||
|
{showExternalInput && renderExternalInput("cc")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MailRecipientSelectorComponent;
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 수신자 선택 컴포넌트 설정 패널
|
||||||
|
* 화면관리에서 컴포넌트 설정 시 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { MailRecipientSelectorConfig } from "./types";
|
||||||
|
|
||||||
|
interface MailRecipientSelectorConfigPanelProps {
|
||||||
|
config: MailRecipientSelectorConfig;
|
||||||
|
onConfigChange: (config: MailRecipientSelectorConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MailRecipientSelectorConfigPanel: React.FC<
|
||||||
|
MailRecipientSelectorConfigPanelProps
|
||||||
|
> = ({ config, onConfigChange }) => {
|
||||||
|
// 로컬 상태
|
||||||
|
const [localConfig, setLocalConfig] = useState<MailRecipientSelectorConfig>({
|
||||||
|
toFieldName: "mailTo",
|
||||||
|
ccFieldName: "mailCc",
|
||||||
|
showCc: true,
|
||||||
|
showInternalSelector: true,
|
||||||
|
showExternalInput: true,
|
||||||
|
toLabel: "수신자",
|
||||||
|
ccLabel: "참조(CC)",
|
||||||
|
required: true,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
// config prop 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalConfig({
|
||||||
|
toFieldName: "mailTo",
|
||||||
|
ccFieldName: "mailCc",
|
||||||
|
showCc: true,
|
||||||
|
showInternalSelector: true,
|
||||||
|
showExternalInput: true,
|
||||||
|
toLabel: "수신자",
|
||||||
|
ccLabel: "참조(CC)",
|
||||||
|
required: true,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 설정 업데이트 함수
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(key: keyof MailRecipientSelectorConfig, value: any) => {
|
||||||
|
const newConfig = { ...localConfig, [key]: value };
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
},
|
||||||
|
[localConfig, onConfigChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 필드명 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-sm">필드명 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">수신자 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.toFieldName || "mailTo"}
|
||||||
|
onChange={(e) => updateConfig("toFieldName", e.target.value)}
|
||||||
|
placeholder="mailTo"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
formData에 저장될 수신자 필드명 (제어에서 {`{{mailTo}}`}로 사용)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">참조 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.ccFieldName || "mailCc"}
|
||||||
|
onChange={(e) => updateConfig("ccFieldName", e.target.value)}
|
||||||
|
placeholder="mailCc"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
formData에 저장될 참조 필드명 (제어에서 {`{{mailCc}}`}로 사용)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 표시 옵션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-sm">표시 옵션</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">참조(CC) 필드 표시</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">참조 수신자 입력 필드 표시</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.showCc !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showCc", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">내부 인원 선택</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">회사 내부 사용자 선택 기능</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.showInternalSelector !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig("showInternalSelector", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">외부 이메일 입력</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">외부 이메일 직접 입력 기능</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.showExternalInput !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig("showExternalInput", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필수 입력</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">수신자 필수 입력 여부</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.required !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("required", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 라벨 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-sm">라벨 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">수신자 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.toLabel || "수신자"}
|
||||||
|
onChange={(e) => updateConfig("toLabel", e.target.value)}
|
||||||
|
placeholder="수신자"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">참조 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.ccLabel || "참조(CC)"}
|
||||||
|
onChange={(e) => updateConfig("ccLabel", e.target.value)}
|
||||||
|
placeholder="참조(CC)"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 제한 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3">
|
||||||
|
<CardTitle className="text-sm">제한 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">최대 수신자 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={localConfig.maxRecipients || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig(
|
||||||
|
"maxRecipients",
|
||||||
|
e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="무제한"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">최대 참조 수신자 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={localConfig.maxCcRecipients || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig(
|
||||||
|
"maxCcRecipients",
|
||||||
|
e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="무제한"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용 안내 */}
|
||||||
|
<Card className="bg-blue-50">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="text-xs text-blue-700">
|
||||||
|
<p className="font-medium mb-1">사용 방법</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>이 컴포넌트를 모달 화면에 배치합니다.</li>
|
||||||
|
<li>
|
||||||
|
제어관리의 메일 발송 노드에서 수신자를{" "}
|
||||||
|
<code className="bg-blue-100 px-1 rounded">{`{{mailTo}}`}</code>로 설정합니다.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
참조는{" "}
|
||||||
|
<code className="bg-blue-100 px-1 rounded">{`{{mailCc}}`}</code>로 설정합니다.
|
||||||
|
</li>
|
||||||
|
<li>부모 화면에서 선택한 데이터는 별도 변수로 접근 가능합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MailRecipientSelectorConfigPanel;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 수신자 선택 컴포넌트 렌더러
|
||||||
|
* ComponentRegistry에 자동 등록하기 위한 클래스
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { MailRecipientSelectorDefinition } from "./index";
|
||||||
|
import { MailRecipientSelectorComponent } from "./MailRecipientSelectorComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MailRecipientSelector 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class MailRecipientSelectorRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = MailRecipientSelectorDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <MailRecipientSelectorComponent {...this.props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
MailRecipientSelectorRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
MailRecipientSelectorRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MailRecipientSelectorRenderer;
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { MailRecipientSelectorComponent } from "./MailRecipientSelectorComponent";
|
||||||
|
import { MailRecipientSelectorConfigPanel } from "./MailRecipientSelectorConfigPanel";
|
||||||
|
import type { MailRecipientSelectorConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MailRecipientSelector 컴포넌트 정의
|
||||||
|
* 메일 수신자 선택 컴포넌트 (내부 인원 + 외부 이메일)
|
||||||
|
*/
|
||||||
|
export const MailRecipientSelectorDefinition = createComponentDefinition({
|
||||||
|
id: "mail-recipient-selector",
|
||||||
|
name: "메일 수신자 선택",
|
||||||
|
nameEng: "Mail Recipient Selector",
|
||||||
|
description: "메일 발송 시 수신자/참조를 선택하는 컴포넌트 (내부 인원 선택 + 외부 이메일 입력)",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "custom" as any,
|
||||||
|
component: MailRecipientSelectorComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
toFieldName: "mailTo",
|
||||||
|
ccFieldName: "mailCc",
|
||||||
|
showCc: true,
|
||||||
|
showInternalSelector: true,
|
||||||
|
showExternalInput: true,
|
||||||
|
toLabel: "수신자",
|
||||||
|
ccLabel: "참조(CC)",
|
||||||
|
required: true,
|
||||||
|
} as MailRecipientSelectorConfig,
|
||||||
|
defaultSize: { width: 400, height: 200 },
|
||||||
|
configPanel: MailRecipientSelectorConfigPanel,
|
||||||
|
icon: "Mail",
|
||||||
|
tags: ["메일", "수신자", "이메일", "선택"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type { MailRecipientSelectorConfig, Recipient, InternalUser } from "./types";
|
||||||
|
|
||||||
|
// 컴포넌트 내보내기
|
||||||
|
export { MailRecipientSelectorComponent } from "./MailRecipientSelectorComponent";
|
||||||
|
export { MailRecipientSelectorRenderer } from "./MailRecipientSelectorRenderer";
|
||||||
|
export { MailRecipientSelectorConfigPanel } from "./MailRecipientSelectorConfigPanel";
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* 메일 수신자 선택 컴포넌트 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 수신자 정보
|
||||||
|
export interface Recipient {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
type: "internal" | "external"; // 내부 사용자 또는 외부 이메일
|
||||||
|
userId?: string; // 내부 사용자인 경우 사용자 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 설정
|
||||||
|
export interface MailRecipientSelectorConfig {
|
||||||
|
// 기본 설정
|
||||||
|
toFieldName?: string; // formData에 저장할 수신자 필드명 (기본: mailTo)
|
||||||
|
ccFieldName?: string; // formData에 저장할 참조 필드명 (기본: mailCc)
|
||||||
|
|
||||||
|
// 표시 옵션
|
||||||
|
showCc?: boolean; // 참조(CC) 필드 표시 여부 (기본: true)
|
||||||
|
showInternalSelector?: boolean; // 내부 인원 선택 표시 여부 (기본: true)
|
||||||
|
showExternalInput?: boolean; // 외부 이메일 입력 표시 여부 (기본: true)
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
toLabel?: string; // 수신자 라벨 (기본: "수신자")
|
||||||
|
ccLabel?: string; // 참조 라벨 (기본: "참조(CC)")
|
||||||
|
|
||||||
|
// 제한
|
||||||
|
maxRecipients?: number; // 최대 수신자 수 (기본: 무제한)
|
||||||
|
maxCcRecipients?: number; // 최대 참조 수신자 수 (기본: 무제한)
|
||||||
|
|
||||||
|
// 필수 여부
|
||||||
|
required?: boolean; // 수신자 필수 입력 여부 (기본: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 Props
|
||||||
|
export interface MailRecipientSelectorProps {
|
||||||
|
// 기본 Props
|
||||||
|
id?: string;
|
||||||
|
config?: MailRecipientSelectorConfig;
|
||||||
|
|
||||||
|
// 폼 데이터 연동
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
// 스타일
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 모드
|
||||||
|
isPreviewMode?: boolean;
|
||||||
|
isInteractive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부 사용자 정보 (API 응답 - camelCase)
|
||||||
|
export interface InternalUser {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
email?: string;
|
||||||
|
deptName?: string;
|
||||||
|
positionName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import type { DataProvidable } from "@/types/data-transfer";
|
import type { DataProvidable } from "@/types/data-transfer";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -26,6 +27,7 @@ export interface SelectBasicComponentProps {
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
value?: any; // 외부에서 전달받는 값
|
value?: any; // 외부에서 전달받는 값
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||||
|
formData?: Record<string, any>; // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +52,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
value: externalValue, // 명시적으로 value prop 받기
|
value: externalValue, // 명시적으로 value prop 받기
|
||||||
menuObjid, // 🆕 메뉴 OBJID
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
|
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 읽기전용/비활성화 상태 확인
|
// 🆕 읽기전용/비활성화 상태 확인
|
||||||
|
|
@ -151,6 +154,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
||||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정 확인
|
||||||
|
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
||||||
|
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
||||||
|
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
||||||
|
// 자식 역할일 때만 부모 값 필요
|
||||||
|
const parentValue = cascadingRole === "child" && cascadingParentField && formData
|
||||||
|
? formData[cascadingParentField]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드)
|
||||||
|
const {
|
||||||
|
options: cascadingOptions,
|
||||||
|
loading: isLoadingCascading,
|
||||||
|
} = useCascadingDropdown({
|
||||||
|
relationCode: cascadingRelationCode,
|
||||||
|
role: cascadingRole, // 부모/자식 역할 전달
|
||||||
|
parentValue: parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webType === "category" && component.tableName && component.columnName) {
|
if (webType === "category" && component.tableName && component.columnName) {
|
||||||
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||||
|
|
@ -301,12 +323,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 선택된 값에 따른 라벨 업데이트
|
// 선택된 값에 따른 라벨 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllOptions = () => {
|
const getAllOptionsForLabel = () => {
|
||||||
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
|
if (cascadingRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = getAllOptions();
|
const options = getAllOptionsForLabel();
|
||||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||||
|
|
||||||
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
||||||
|
|
@ -327,7 +353,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
if (newLabel !== selectedLabel) {
|
if (newLabel !== selectedLabel) {
|
||||||
setSelectedLabel(newLabel);
|
setSelectedLabel(newLabel);
|
||||||
}
|
}
|
||||||
}, [selectedValue, codeOptions, config.options]);
|
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
|
@ -378,6 +404,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
|
if (cascadingRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
|
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link2, ExternalLink } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { SelectBasicConfig } from "./types";
|
import { SelectBasicConfig } from "./types";
|
||||||
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
export interface SelectBasicConfigPanelProps {
|
export interface SelectBasicConfigPanelProps {
|
||||||
config: SelectBasicConfig;
|
config: SelectBasicConfig;
|
||||||
onChange: (config: Partial<SelectBasicConfig>) => void;
|
onChange: (config: Partial<SelectBasicConfig>) => void;
|
||||||
|
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
|
||||||
|
allComponents?: any[];
|
||||||
|
/** 현재 컴포넌트 정보 */
|
||||||
|
currentComponent?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,13 +28,127 @@ export interface SelectBasicConfigPanelProps {
|
||||||
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
allComponents = [],
|
||||||
|
currentComponent,
|
||||||
}) => {
|
}) => {
|
||||||
|
// 연쇄 드롭다운 관련 상태
|
||||||
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (cascadingEnabled && relationList.length === 0) {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// config 변경 시 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||||
|
}, [config.cascadingRelationCode]);
|
||||||
|
|
||||||
|
const loadRelationList = async () => {
|
||||||
|
setLoadingRelations(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelationList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||||
const newConfig = { ...config, [key]: value };
|
const newConfig = { ...config, [key]: value };
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 토글
|
||||||
|
const handleCascadingToggle = (enabled: boolean) => {
|
||||||
|
setCascadingEnabled(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
// 비활성화 시 관계 설정 제거
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
cascadingRelationCode: undefined,
|
||||||
|
cascadingRole: undefined,
|
||||||
|
cascadingParentField: undefined,
|
||||||
|
};
|
||||||
|
onChange(newConfig);
|
||||||
|
} else {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 같은 연쇄 관계의 부모 역할 컴포넌트 찾기
|
||||||
|
const findParentComponent = (relationCode: string) => {
|
||||||
|
console.log("🔍 findParentComponent 호출:", {
|
||||||
|
relationCode,
|
||||||
|
allComponentsLength: allComponents?.length,
|
||||||
|
currentComponentId: currentComponent?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allComponents || allComponents.length === 0) {
|
||||||
|
console.log("❌ allComponents가 비어있음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 컴포넌트의 cascading 설정 확인
|
||||||
|
allComponents.forEach((comp: any) => {
|
||||||
|
const compConfig = comp.componentConfig || {};
|
||||||
|
if (compConfig.cascadingRelationCode) {
|
||||||
|
console.log("📦 컴포넌트 cascading 설정:", {
|
||||||
|
id: comp.id,
|
||||||
|
columnName: comp.columnName,
|
||||||
|
cascadingRelationCode: compConfig.cascadingRelationCode,
|
||||||
|
cascadingRole: compConfig.cascadingRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = allComponents.find((comp: any) => {
|
||||||
|
const compConfig = comp.componentConfig || {};
|
||||||
|
return (
|
||||||
|
comp.id !== currentComponent?.id && // 자기 자신 제외
|
||||||
|
compConfig.cascadingRelationCode === relationCode &&
|
||||||
|
compConfig.cascadingRole === "parent"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 찾은 부모 컴포넌트:", found);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 역할 변경 시 부모 필드 자동 감지
|
||||||
|
const handleRoleChange = (role: "parent" | "child") => {
|
||||||
|
let parentField = config.cascadingParentField;
|
||||||
|
|
||||||
|
// 자식 역할 선택 시 부모 필드 자동 감지
|
||||||
|
if (role === "child" && config.cascadingRelationCode) {
|
||||||
|
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||||
|
if (parentComp) {
|
||||||
|
parentField = parentComp.columnName;
|
||||||
|
console.log("🔗 부모 필드 자동 감지:", parentField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
cascadingRole: role,
|
||||||
|
// 부모 역할일 때는 부모 필드 불필요, 자식일 때는 자동 감지된 값 또는 기존 값
|
||||||
|
cascadingParentField: role === "parent" ? undefined : parentField,
|
||||||
|
};
|
||||||
|
onChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 관계 정보
|
||||||
|
const selectedRelation = relationList.find(r => r.relation_code === config.cascadingRelationCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
|
|
@ -78,6 +201,179 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 연쇄 드롭다운 설정 */}
|
||||||
|
<div className="border-t pt-4 mt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<Label className="text-sm font-medium">연쇄 드롭다운</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cascadingEnabled}
|
||||||
|
onCheckedChange={handleCascadingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
다른 필드의 값에 따라 옵션이 동적으로 변경됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cascadingEnabled && (
|
||||||
|
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
|
||||||
|
{/* 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">연쇄 관계 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cascadingRelationCode || ""}
|
||||||
|
onValueChange={(value) => handleChange("cascadingRelationCode", value || undefined)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-xs">
|
||||||
|
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relationList.map((relation) => (
|
||||||
|
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{relation.relation_name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{relation.parent_table} → {relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 역할 선택 */}
|
||||||
|
{config.cascadingRelationCode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">역할 선택</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={config.cascadingRole === "parent" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("parent")}
|
||||||
|
>
|
||||||
|
부모 (상위 선택)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={config.cascadingRole === "child" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("child")}
|
||||||
|
>
|
||||||
|
자식 (하위 선택)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{config.cascadingRole === "parent"
|
||||||
|
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
|
||||||
|
: config.cascadingRole === "child"
|
||||||
|
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
|
||||||
|
: "이 필드의 역할을 선택하세요."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||||
|
{config.cascadingRelationCode && config.cascadingRole === "child" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">부모 필드명</Label>
|
||||||
|
{(() => {
|
||||||
|
const parentComp = findParentComponent(config.cascadingRelationCode);
|
||||||
|
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
value={config.cascadingParentField || ""}
|
||||||
|
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
|
||||||
|
placeholder="예: warehouse_code"
|
||||||
|
className="text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{parentComp && !isAutoDetected && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
|
||||||
|
>
|
||||||
|
자동감지
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAutoDetected ? (
|
||||||
|
<p className="text-xs text-green-600">
|
||||||
|
자동 감지됨: {parentComp.label || parentComp.columnName}
|
||||||
|
</p>
|
||||||
|
) : parentComp ? (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
감지된 부모 필드: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
같은 관계의 부모 역할 필드가 없습니다. 수동으로 입력하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 관계 정보 표시 */}
|
||||||
|
{selectedRelation && config.cascadingRole && (
|
||||||
|
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
|
||||||
|
{config.cascadingRole === "parent" ? (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-blue-600">부모 역할 (상위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_value_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-green-600">자식 역할 (하위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">필터 기준:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_filter_column}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_value_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관계 관리 페이지 링크 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/admin/cascading-relations" target="_blank">
|
||||||
|
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
|
관계 관리
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface SelectBasicConfig extends ComponentConfig {
|
||||||
// 코드 관련 설정
|
// 코드 관련 설정
|
||||||
codeCategory?: string;
|
codeCategory?: string;
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정
|
||||||
|
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
|
cascadingRelationCode?: string;
|
||||||
|
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
|
||||||
|
cascadingRole?: "parent" | "child";
|
||||||
|
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
// 공통 설정
|
// 공통 설정
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,21 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import {
|
import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig } from "./types";
|
||||||
SplitPanelLayout2Config,
|
|
||||||
ColumnConfig,
|
|
||||||
DataTransferField,
|
|
||||||
ActionButtonConfig,
|
|
||||||
JoinTableConfig,
|
|
||||||
} from "./types";
|
|
||||||
import { defaultConfig } from "./config";
|
import { defaultConfig } from "./config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react";
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
MoreHorizontal,
|
||||||
|
} from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -23,14 +28,7 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
@ -88,7 +86,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
||||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
const loadLeftData = useCallback(async () => {
|
const loadLeftData = useCallback(async () => {
|
||||||
if (!config.leftPanel?.tableName || isDesignMode) return;
|
if (!config.leftPanel?.tableName || isDesignMode) return;
|
||||||
|
|
@ -114,7 +111,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
data = buildHierarchy(
|
data = buildHierarchy(
|
||||||
data,
|
data,
|
||||||
config.leftPanel.hierarchyConfig.idColumn,
|
config.leftPanel.hierarchyConfig.idColumn,
|
||||||
config.leftPanel.hierarchyConfig.parentColumn
|
config.leftPanel.hierarchyConfig.parentColumn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,10 +127,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]);
|
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]);
|
||||||
|
|
||||||
// 조인 테이블 데이터 로드 (단일 테이블)
|
// 조인 테이블 데이터 로드 (단일 테이블)
|
||||||
const loadJoinTableData = useCallback(async (
|
const loadJoinTableData = useCallback(
|
||||||
joinConfig: JoinTableConfig,
|
async (joinConfig: JoinTableConfig, mainData: any[]): Promise<Map<string, any>> => {
|
||||||
mainData: any[]
|
|
||||||
): Promise<Map<string, any>> => {
|
|
||||||
const resultMap = new Map<string, any>();
|
const resultMap = new Map<string, any>();
|
||||||
if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) {
|
if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) {
|
||||||
return resultMap;
|
return resultMap;
|
||||||
|
|
@ -184,14 +179,13 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultMap;
|
return resultMap;
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 메인 데이터에 조인 테이블 데이터 병합
|
// 메인 데이터에 조인 테이블 데이터 병합
|
||||||
const mergeJoinData = useCallback((
|
const mergeJoinData = useCallback(
|
||||||
mainData: any[],
|
(mainData: any[], joinConfig: JoinTableConfig, joinDataMap: Map<string, any>): any[] => {
|
||||||
joinConfig: JoinTableConfig,
|
|
||||||
joinDataMap: Map<string, any>
|
|
||||||
): any[] => {
|
|
||||||
return mainData.map((item) => {
|
return mainData.map((item) => {
|
||||||
const joinKey = item[joinConfig.mainColumn];
|
const joinKey = item[joinConfig.mainColumn];
|
||||||
const joinRow = joinDataMap.get(String(joinKey));
|
const joinRow = joinDataMap.get(String(joinKey));
|
||||||
|
|
@ -214,31 +208,84 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
mergedItem[targetKey] = joinRow[col];
|
mergedItem[targetKey] = joinRow[col];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) });
|
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, {
|
||||||
|
mainKey: joinKey,
|
||||||
|
mergedKeys: Object.keys(mergedItem),
|
||||||
|
});
|
||||||
return mergedItem;
|
return mergedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 우측 데이터 로드 (좌측 선택 항목 기반)
|
// 우측 데이터 로드 (좌측 선택 항목 기반)
|
||||||
const loadRightData = useCallback(async (selectedItem: any) => {
|
const loadRightData = useCallback(
|
||||||
if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) {
|
async (selectedItem: any) => {
|
||||||
|
if (!config.rightPanel?.tableName || !selectedItem) {
|
||||||
setRightData([]);
|
setRightData([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinValue = selectedItem[config.joinConfig.leftColumn];
|
// 복합키 또는 단일키 처리
|
||||||
if (joinValue === undefined || joinValue === null) {
|
const joinKeys = config.joinConfig?.keys || [];
|
||||||
console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`);
|
const hasCompositeKeys = joinKeys.length > 0;
|
||||||
|
const hasSingleKey = config.joinConfig?.leftColumn && config.joinConfig?.rightColumn;
|
||||||
|
|
||||||
|
if (!hasCompositeKeys && !hasSingleKey) {
|
||||||
|
console.log(`[SplitPanelLayout2] 조인 설정이 없음`);
|
||||||
setRightData([]);
|
setRightData([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 필터 배열 생성
|
||||||
|
const filters: any[] = [];
|
||||||
|
|
||||||
|
if (hasCompositeKeys) {
|
||||||
|
// 복합키 처리
|
||||||
|
for (let i = 0; i < joinKeys.length; i++) {
|
||||||
|
const key = joinKeys[i];
|
||||||
|
const joinValue = selectedItem[key.leftColumn];
|
||||||
|
if (joinValue === undefined || joinValue === null) {
|
||||||
|
console.log(`[SplitPanelLayout2] 복합키 조인 값이 없음: ${key.leftColumn}`);
|
||||||
|
setRightData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filters.push({
|
||||||
|
id: `join_filter_${i}`,
|
||||||
|
columnName: key.rightColumn,
|
||||||
|
operator: "equals",
|
||||||
|
value: String(joinValue),
|
||||||
|
valueType: "static",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[SplitPanelLayout2] 복합키 조인: ${joinKeys.map((k) => `${k.leftColumn}→${k.rightColumn}`).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 단일키 처리 (하위 호환성)
|
||||||
|
const joinValue = selectedItem[config.joinConfig!.leftColumn!];
|
||||||
|
if (joinValue === undefined || joinValue === null) {
|
||||||
|
console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig!.leftColumn}`);
|
||||||
|
setRightData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filters.push({
|
||||||
|
id: "join_filter",
|
||||||
|
columnName: config.joinConfig!.rightColumn,
|
||||||
|
operator: "equals",
|
||||||
|
value: String(joinValue),
|
||||||
|
valueType: "static",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setRightLoading(true);
|
setRightLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`);
|
console.log(
|
||||||
|
`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, 필터 ${filters.length}개`,
|
||||||
|
);
|
||||||
|
|
||||||
const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -247,15 +294,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
dataFilter: {
|
dataFilter: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
matchType: "all",
|
matchType: "all",
|
||||||
filters: [
|
filters,
|
||||||
{
|
|
||||||
id: "join_filter",
|
|
||||||
columnName: config.joinConfig.rightColumn,
|
|
||||||
operator: "equals",
|
|
||||||
value: String(joinValue),
|
|
||||||
valueType: "static",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
// 멀티테넌시: 자동으로 company_code 필터링 적용
|
// 멀티테넌시: 자동으로 company_code 필터링 적용
|
||||||
autoFilter: {
|
autoFilter: {
|
||||||
|
|
@ -301,13 +340,15 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
url: error?.config?.url,
|
url: error?.config?.url,
|
||||||
method: error?.config?.method,
|
method: error?.config?.method,
|
||||||
data: error?.config?.data,
|
data: error?.config?.data,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
setRightData([]);
|
setRightData([]);
|
||||||
} finally {
|
} finally {
|
||||||
setRightLoading(false);
|
setRightLoading(false);
|
||||||
}
|
}
|
||||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]);
|
},
|
||||||
|
[config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData],
|
||||||
|
);
|
||||||
|
|
||||||
// 좌측 패널 추가 버튼 클릭
|
// 좌측 패널 추가 버튼 클릭
|
||||||
const handleLeftAddClick = useCallback(() => {
|
const handleLeftAddClick = useCallback(() => {
|
||||||
|
|
@ -370,7 +411,13 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
||||||
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
|
}, [
|
||||||
|
config.rightPanel?.addModalScreenId,
|
||||||
|
config.rightPanel?.addButtonLabel,
|
||||||
|
config.dataTransferFields,
|
||||||
|
selectedLeftItem,
|
||||||
|
loadRightData,
|
||||||
|
]);
|
||||||
|
|
||||||
// 기본키 컬럼명 가져오기
|
// 기본키 컬럼명 가져오기
|
||||||
const getPrimaryKeyColumn = useCallback(() => {
|
const getPrimaryKeyColumn = useCallback(() => {
|
||||||
|
|
@ -378,7 +425,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}, [config.rightPanel?.primaryKeyColumn]);
|
}, [config.rightPanel?.primaryKeyColumn]);
|
||||||
|
|
||||||
// 우측 패널 수정 버튼 클릭
|
// 우측 패널 수정 버튼 클릭
|
||||||
const handleEditItem = useCallback((item: any) => {
|
const handleEditItem = useCallback(
|
||||||
|
(item: any) => {
|
||||||
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
|
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
|
||||||
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
||||||
|
|
||||||
|
|
@ -404,7 +452,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
|
console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
|
||||||
}, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]);
|
},
|
||||||
|
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData],
|
||||||
|
);
|
||||||
|
|
||||||
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
|
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
|
||||||
const handleDeleteClick = useCallback((item: any) => {
|
const handleDeleteClick = useCallback((item: any) => {
|
||||||
|
|
@ -465,7 +515,15 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
setItemToDelete(null);
|
setItemToDelete(null);
|
||||||
setIsBulkDelete(false);
|
setIsBulkDelete(false);
|
||||||
}
|
}
|
||||||
}, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]);
|
}, [
|
||||||
|
config.rightPanel?.tableName,
|
||||||
|
getPrimaryKeyColumn,
|
||||||
|
isBulkDelete,
|
||||||
|
selectedRightItems,
|
||||||
|
itemToDelete,
|
||||||
|
selectedLeftItem,
|
||||||
|
loadRightData,
|
||||||
|
]);
|
||||||
|
|
||||||
// 개별 체크박스 선택/해제
|
// 개별 체크박스 선택/해제
|
||||||
const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => {
|
const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => {
|
||||||
|
|
@ -481,7 +539,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 액션 버튼 클릭 핸들러
|
// 액션 버튼 클릭 핸들러
|
||||||
const handleActionButton = useCallback((btn: ActionButtonConfig) => {
|
const handleActionButton = useCallback(
|
||||||
|
(btn: ActionButtonConfig) => {
|
||||||
switch (btn.action) {
|
switch (btn.action) {
|
||||||
case "add":
|
case "add":
|
||||||
if (btn.modalScreenId) {
|
if (btn.modalScreenId) {
|
||||||
|
|
@ -542,10 +601,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]);
|
},
|
||||||
|
[
|
||||||
|
selectedLeftItem,
|
||||||
|
config.dataTransferFields,
|
||||||
|
loadRightData,
|
||||||
|
selectedRightItems,
|
||||||
|
getPrimaryKeyColumn,
|
||||||
|
rightData,
|
||||||
|
handleEditItem,
|
||||||
|
handleBulkDeleteClick,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 컬럼 라벨 로드
|
// 컬럼 라벨 로드
|
||||||
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
|
const loadColumnLabels = useCallback(
|
||||||
|
async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
|
||||||
if (!tableName) return;
|
if (!tableName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -566,7 +637,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error);
|
console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 계층 구조 빌드
|
// 계층 구조 빌드
|
||||||
const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => {
|
const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => {
|
||||||
|
|
@ -594,7 +667,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
};
|
};
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러
|
// 좌측 항목 선택 핸들러
|
||||||
const handleLeftItemSelect = useCallback((item: any) => {
|
const handleLeftItemSelect = useCallback(
|
||||||
|
(item: any) => {
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
loadRightData(item);
|
loadRightData(item);
|
||||||
|
|
||||||
|
|
@ -609,7 +683,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
});
|
});
|
||||||
console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`);
|
console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`);
|
||||||
}
|
}
|
||||||
}, [isDesignMode, screenContext, component.id, leftData, loadRightData]);
|
},
|
||||||
|
[isDesignMode, screenContext, component.id, leftData, loadRightData],
|
||||||
|
);
|
||||||
|
|
||||||
// 항목 확장/축소 토글
|
// 항목 확장/축소 토글
|
||||||
const toggleExpand = useCallback((itemId: string) => {
|
const toggleExpand = useCallback((itemId: string) => {
|
||||||
|
|
@ -678,7 +754,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
|
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
|
||||||
|
|
||||||
// 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함)
|
// 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함)
|
||||||
const handleSelectAll = useCallback((checked: boolean) => {
|
const handleSelectAll = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const pkColumn = getPrimaryKeyColumn();
|
const pkColumn = getPrimaryKeyColumn();
|
||||||
const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
|
const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
|
||||||
|
|
@ -686,16 +763,22 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
} else {
|
} else {
|
||||||
setSelectedRightItems(new Set());
|
setSelectedRightItems(new Set());
|
||||||
}
|
}
|
||||||
}, [filteredRightData, getPrimaryKeyColumn]);
|
},
|
||||||
|
[filteredRightData, getPrimaryKeyColumn],
|
||||||
|
);
|
||||||
|
|
||||||
// 리사이즈 핸들러
|
// 리사이즈 핸들러
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
if (!config.resizable) return;
|
if (!config.resizable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
}, [config.resizable]);
|
},
|
||||||
|
[config.resizable],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
const handleResizeMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
|
|
||||||
const container = document.getElementById(`split-panel-${component.id}`);
|
const container = document.getElementById(`split-panel-${component.id}`);
|
||||||
|
|
@ -703,11 +786,13 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
const minLeft = (config.minLeftWidth || 200) / rect.width * 100;
|
const minLeft = ((config.minLeftWidth || 200) / rect.width) * 100;
|
||||||
const minRight = (config.minRightWidth || 300) / rect.width * 100;
|
const minRight = ((config.minRightWidth || 300) / rect.width) * 100;
|
||||||
|
|
||||||
setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition)));
|
setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition)));
|
||||||
}, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]);
|
},
|
||||||
|
[isResizing, component.id, config.minLeftWidth, config.minRightWidth],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeEnd = useCallback(() => {
|
const handleResizeEnd = useCallback(() => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
|
|
@ -732,7 +817,14 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels);
|
loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels);
|
||||||
loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels);
|
loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels);
|
||||||
}
|
}
|
||||||
}, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]);
|
}, [
|
||||||
|
config.autoLoad,
|
||||||
|
isDesignMode,
|
||||||
|
loadLeftData,
|
||||||
|
loadColumnLabels,
|
||||||
|
config.leftPanel?.tableName,
|
||||||
|
config.rightPanel?.tableName,
|
||||||
|
]);
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 DataProvider 해제
|
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -744,7 +836,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}, [screenContext, component.id]);
|
}, [screenContext, component.id]);
|
||||||
|
|
||||||
// 컬럼 값 가져오기 (sourceTable 고려)
|
// 컬럼 값 가져오기 (sourceTable 고려)
|
||||||
const getColumnValue = useCallback((item: any, col: ColumnConfig): any => {
|
const getColumnValue = useCallback(
|
||||||
|
(item: any, col: ColumnConfig): any => {
|
||||||
// col.name이 "테이블명.컬럼명" 형식인 경우 처리
|
// col.name이 "테이블명.컬럼명" 형식인 경우 처리
|
||||||
const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
||||||
const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null;
|
const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null;
|
||||||
|
|
@ -758,7 +851,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return item[tableColumnKey];
|
return item[tableColumnKey];
|
||||||
}
|
}
|
||||||
// 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도
|
// 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도
|
||||||
const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable);
|
const joinTable = config.rightPanel?.joinTables?.find((jt) => jt.joinTable === effectiveSourceTable);
|
||||||
if (joinTable?.alias) {
|
if (joinTable?.alias) {
|
||||||
const aliasKey = `${joinTable.alias}_${actualColName}`;
|
const aliasKey = `${joinTable.alias}_${actualColName}`;
|
||||||
if (item[aliasKey] !== undefined) {
|
if (item[aliasKey] !== undefined) {
|
||||||
|
|
@ -772,7 +865,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
}
|
}
|
||||||
// 4. 기본: 컬럼명으로 직접 접근
|
// 4. 기본: 컬럼명으로 직접 접근
|
||||||
return item[actualColName];
|
return item[actualColName];
|
||||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables]);
|
},
|
||||||
|
[config.rightPanel?.tableName, config.rightPanel?.joinTables],
|
||||||
|
);
|
||||||
|
|
||||||
// 값 포맷팅
|
// 값 포맷팅
|
||||||
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
|
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
|
||||||
|
|
@ -783,9 +878,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
case "number":
|
case "number":
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (isNaN(num)) return String(value);
|
if (isNaN(num)) return String(value);
|
||||||
let formatted = format.decimalPlaces !== undefined
|
let formatted = format.decimalPlaces !== undefined ? num.toFixed(format.decimalPlaces) : String(num);
|
||||||
? num.toFixed(format.decimalPlaces)
|
|
||||||
: String(num);
|
|
||||||
if (format.thousandSeparator) {
|
if (format.thousandSeparator) {
|
||||||
formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
}
|
}
|
||||||
|
|
@ -831,11 +924,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
|
|
||||||
// displayRow 설정에 따라 컬럼 분류
|
// displayRow 설정에 따라 컬럼 분류
|
||||||
const displayColumns = config.leftPanel?.displayColumns || [];
|
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||||
const nameRowColumns = displayColumns.filter((col, idx) =>
|
const nameRowColumns = displayColumns.filter(
|
||||||
col.displayRow === "name" || (!col.displayRow && idx === 0)
|
(col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0),
|
||||||
);
|
);
|
||||||
const infoRowColumns = displayColumns.filter((col, idx) =>
|
const infoRowColumns = displayColumns.filter(
|
||||||
col.displayRow === "info" || (!col.displayRow && idx > 0)
|
(col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 이름 행의 첫 번째 값 (주요 표시 값)
|
// 이름 행의 첫 번째 값 (주요 표시 값)
|
||||||
|
|
@ -847,9 +940,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
<div key={itemId}>
|
<div key={itemId}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-4 py-3 cursor-pointer rounded-md transition-colors",
|
"flex cursor-pointer items-center gap-3 rounded-md px-4 py-3 transition-colors",
|
||||||
"hover:bg-accent",
|
"hover:bg-accent",
|
||||||
isSelected && "bg-primary/10 border-l-2 border-primary"
|
isSelected && "bg-primary/10 border-primary border-l-2",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${level * 16 + 16}px` }}
|
style={{ paddingLeft: `${level * 16 + 16}px` }}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
|
|
@ -857,16 +950,16 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 확장/축소 버튼 */}
|
{/* 확장/축소 버튼 */}
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
className="p-0.5 hover:bg-accent rounded"
|
className="hover:bg-accent rounded p-0.5"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleExpand(String(itemId));
|
toggleExpand(String(itemId));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -874,21 +967,19 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 아이콘 */}
|
{/* 아이콘 */}
|
||||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
<Building2 className="text-muted-foreground h-5 w-5" />
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내용 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
{/* 이름 행 (Name Row) */}
|
{/* 이름 행 (Name Row) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-base truncate">
|
<span className="truncate text-base font-medium">{primaryValue || "이름 없음"}</span>
|
||||||
{primaryValue || "이름 없음"}
|
|
||||||
</span>
|
|
||||||
{/* 이름 행의 추가 컬럼들 (배지 스타일) */}
|
{/* 이름 행의 추가 컬럼들 (배지 스타일) */}
|
||||||
{nameRowColumns.slice(1).map((col, idx) => {
|
{nameRowColumns.slice(1).map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = item[col.name];
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">
|
<span key={idx} className="bg-muted shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||||
{formatValue(value, col.format)}
|
{formatValue(value, col.format)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -896,17 +987,21 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
</div>
|
</div>
|
||||||
{/* 정보 행 (Info Row) */}
|
{/* 정보 행 (Info Row) */}
|
||||||
{infoRowColumns.length > 0 && (
|
{infoRowColumns.length > 0 && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground truncate">
|
<div className="text-muted-foreground flex items-center gap-2 truncate text-sm">
|
||||||
{infoRowColumns.map((col, idx) => {
|
{infoRowColumns
|
||||||
|
.map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = item[col.name];
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return <span key={idx}>{formatValue(value, col.format)}</span>;
|
||||||
<span key={idx}>
|
})
|
||||||
{formatValue(value, col.format)}
|
.filter(Boolean)
|
||||||
</span>
|
.reduce((acc: React.ReactNode[], curr, idx) => {
|
||||||
|
if (idx > 0)
|
||||||
|
acc.push(
|
||||||
|
<span key={`sep-${idx}`} className="text-muted-foreground/50">
|
||||||
|
|
|
||||||
|
</span>,
|
||||||
);
|
);
|
||||||
}).filter(Boolean).reduce((acc: React.ReactNode[], curr, idx) => {
|
|
||||||
if (idx > 0) acc.push(<span key={`sep-${idx}`} className="text-muted-foreground/50">|</span>);
|
|
||||||
acc.push(curr);
|
acc.push(curr);
|
||||||
return acc;
|
return acc;
|
||||||
}, [])}
|
}, [])}
|
||||||
|
|
@ -935,15 +1030,15 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
|
|
||||||
// displayRow 설정에 따라 컬럼 분류
|
// displayRow 설정에 따라 컬럼 분류
|
||||||
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
|
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
|
||||||
const nameRowColumns = displayColumns.filter((col, idx) =>
|
const nameRowColumns = displayColumns.filter(
|
||||||
col.displayRow === "name" || (!col.displayRow && idx === 0)
|
(col, idx) => col.displayRow === "name" || (!col.displayRow && idx === 0),
|
||||||
);
|
);
|
||||||
const infoRowColumns = displayColumns.filter((col, idx) =>
|
const infoRowColumns = displayColumns.filter(
|
||||||
col.displayRow === "info" || (!col.displayRow && idx > 0)
|
(col, idx) => col.displayRow === "info" || (!col.displayRow && idx > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
|
<Card key={index} className="mb-2 py-0 transition-shadow hover:shadow-md">
|
||||||
<CardContent className="px-4 py-2">
|
<CardContent className="px-4 py-2">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
|
|
@ -967,7 +1062,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="flex items-center gap-1">
|
<span key={idx} className="flex items-center gap-1">
|
||||||
<span className="text-sm text-muted-foreground">{col.label || col.name}:</span>
|
<span className="text-muted-foreground text-sm">{col.label || col.name}:</span>
|
||||||
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
|
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -976,7 +1071,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
)}
|
)}
|
||||||
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
|
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
|
||||||
{infoRowColumns.length > 0 && (
|
{infoRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
{infoRowColumns.map((col, idx) => {
|
{infoRowColumns.map((col, idx) => {
|
||||||
const value = getColumnValue(item, col);
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
|
|
@ -1001,13 +1096,13 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="font-semibold text-base">
|
<span key={idx} className="text-base font-semibold">
|
||||||
{formatValue(value, col.format)}
|
{formatValue(value, col.format)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
|
<span key={idx} className="bg-muted rounded px-2 py-0.5 text-sm">
|
||||||
{formatValue(value, col.format)}
|
{formatValue(value, col.format)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -1016,7 +1111,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
)}
|
)}
|
||||||
{/* 정보 행 */}
|
{/* 정보 행 */}
|
||||||
{infoRowColumns.length > 0 && (
|
{infoRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
<div className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
{infoRowColumns.map((col, idx) => {
|
{infoRowColumns.map((col, idx) => {
|
||||||
const value = getColumnValue(item, col);
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
|
|
@ -1035,12 +1130,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 액션 버튼 (개별 수정/삭제) */}
|
{/* 액션 버튼 (개별 수정/삭제) */}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{config.rightPanel?.showEditButton && (
|
{config.rightPanel?.showEditButton && (
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEditItem(item)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => handleEditItem(item)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1048,7 +1138,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||||||
onClick={() => handleDeleteClick(item)}
|
onClick={() => handleDeleteClick(item)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|
@ -1066,12 +1156,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const displayColumns = config.rightPanel?.displayColumns || [];
|
const displayColumns = config.rightPanel?.displayColumns || [];
|
||||||
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
|
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
|
||||||
const pkColumn = getPrimaryKeyColumn();
|
const pkColumn = getPrimaryKeyColumn();
|
||||||
const allSelected = filteredRightData.length > 0 &&
|
const allSelected =
|
||||||
filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
|
filteredRightData.length > 0 && filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
|
||||||
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
|
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-md">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -1089,10 +1179,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col, idx) => (
|
{displayColumns.map((col, idx) => (
|
||||||
<TableHead
|
<TableHead key={idx} style={{ width: col.width ? `${col.width}px` : "auto" }}>
|
||||||
key={idx}
|
|
||||||
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
|
||||||
>
|
|
||||||
{col.label || col.name}
|
{col.label || col.name}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1105,8 +1192,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{filteredRightData.length === 0 ? (
|
{filteredRightData.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={displayColumns.length + (showCheckbox ? 1 : 0) + ((config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) ? 1 : 0)}
|
colSpan={
|
||||||
className="h-24 text-center text-muted-foreground"
|
displayColumns.length +
|
||||||
|
(showCheckbox ? 1 : 0) +
|
||||||
|
(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton ? 1 : 0)
|
||||||
|
}
|
||||||
|
className="text-muted-foreground h-24 text-center"
|
||||||
>
|
>
|
||||||
등록된 항목이 없습니다
|
등록된 항목이 없습니다
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -1125,9 +1216,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col, colIdx) => (
|
{displayColumns.map((col, colIdx) => (
|
||||||
<TableCell key={colIdx}>
|
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell>
|
||||||
{formatValue(getColumnValue(item, col), col.format)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
))}
|
||||||
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
|
|
@ -1146,7 +1235,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive h-7 w-7"
|
||||||
onClick={() => handleDeleteClick(item)}
|
onClick={() => handleDeleteClick(item)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|
@ -1184,9 +1273,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
|
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{btn.icon === "Plus" && <Plus className="h-4 w-4 mr-1" />}
|
{btn.icon === "Plus" && <Plus className="mr-1 h-4 w-4" />}
|
||||||
{btn.icon === "Edit" && <Edit className="h-4 w-4 mr-1" />}
|
{btn.icon === "Edit" && <Edit className="mr-1 h-4 w-4" />}
|
||||||
{btn.icon === "Trash2" && <Trash2 className="h-4 w-4 mr-1" />}
|
{btn.icon === "Trash2" && <Trash2 className="mr-1 h-4 w-4" />}
|
||||||
{btn.label}
|
{btn.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1199,38 +1288,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full border-2 border-dashed rounded-lg flex",
|
"flex h-full w-full rounded-lg border-2 border-dashed",
|
||||||
isSelected ? "border-primary" : "border-muted-foreground/30"
|
isSelected ? "border-primary" : "border-muted-foreground/30",
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* 좌측 패널 미리보기 */}
|
{/* 좌측 패널 미리보기 */}
|
||||||
<div
|
<div className="bg-muted/30 flex flex-col border-r p-4" style={{ width: `${splitPosition}%` }}>
|
||||||
className="border-r bg-muted/30 p-4 flex flex-col"
|
<div className="mb-2 text-sm font-medium">{config.leftPanel?.title || "좌측 패널"}</div>
|
||||||
style={{ width: `${splitPosition}%` }}
|
<div className="text-muted-foreground mb-2 text-xs">테이블: {config.leftPanel?.tableName || "미설정"}</div>
|
||||||
>
|
<div className="text-muted-foreground flex flex-1 items-center justify-center text-xs">좌측 목록 영역</div>
|
||||||
<div className="text-sm font-medium mb-2">
|
|
||||||
{config.leftPanel?.title || "좌측 패널"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
테이블: {config.leftPanel?.tableName || "미설정"}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
|
|
||||||
좌측 목록 영역
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 패널 미리보기 */}
|
{/* 우측 패널 미리보기 */}
|
||||||
<div className="flex-1 p-4 flex flex-col">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<div className="text-sm font-medium mb-2">
|
<div className="mb-2 text-sm font-medium">{config.rightPanel?.title || "우측 패널"}</div>
|
||||||
{config.rightPanel?.title || "우측 패널"}
|
<div className="text-muted-foreground mb-2 text-xs">테이블: {config.rightPanel?.tableName || "미설정"}</div>
|
||||||
</div>
|
<div className="text-muted-foreground flex flex-1 items-center justify-center text-xs">우측 상세 영역</div>
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
테이블: {config.rightPanel?.tableName || "미설정"}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
|
|
||||||
우측 상세 영역
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1239,21 +1313,21 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`split-panel-${component.id}`}
|
id={`split-panel-${component.id}`}
|
||||||
className="w-full h-full flex bg-background rounded-lg border overflow-hidden"
|
className="bg-background flex h-full w-full overflow-hidden rounded-lg border"
|
||||||
style={{ minHeight: "400px" }}
|
style={{ minHeight: "400px" }}
|
||||||
>
|
>
|
||||||
{/* 좌측 패널 */}
|
{/* 좌측 패널 */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-r bg-card"
|
className="bg-card flex flex-col border-r"
|
||||||
style={{ width: `${splitPosition}%`, minWidth: config.minLeftWidth }}
|
style={{ width: `${splitPosition}%`, minWidth: config.minLeftWidth }}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-4 border-b bg-muted/30">
|
<div className="bg-muted/30 border-b p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-base">{config.leftPanel?.title || "목록"}</h3>
|
<h3 className="text-base font-semibold">{config.leftPanel?.title || "목록"}</h3>
|
||||||
{config.leftPanel?.showAddButton && (
|
{config.leftPanel?.showAddButton && (
|
||||||
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleLeftAddClick}>
|
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleLeftAddClick}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
{config.leftPanel?.addButtonLabel || "추가"}
|
{config.leftPanel?.addButtonLabel || "추가"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1262,12 +1336,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
{config.leftPanel?.showSearch && (
|
{config.leftPanel?.showSearch && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={leftSearchTerm}
|
value={leftSearchTerm}
|
||||||
onChange={(e) => setLeftSearchTerm(e.target.value)}
|
onChange={(e) => setLeftSearchTerm(e.target.value)}
|
||||||
className="pl-9 h-9 text-sm"
|
className="h-9 pl-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1276,17 +1350,13 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 목록 */}
|
{/* 목록 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{leftLoading ? (
|
{leftLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
<div className="text-muted-foreground flex h-full items-center justify-center text-base">로딩 중...</div>
|
||||||
로딩 중...
|
|
||||||
</div>
|
|
||||||
) : filteredLeftData.length === 0 ? (
|
) : filteredLeftData.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
<div className="text-muted-foreground flex h-full items-center justify-center text-base">
|
||||||
데이터가 없습니다
|
데이터가 없습니다
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}</div>
|
||||||
{filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1294,37 +1364,28 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 리사이저 */}
|
{/* 리사이저 */}
|
||||||
{config.resizable && (
|
{config.resizable && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("hover:bg-primary/50 w-1 cursor-col-resize transition-colors", isResizing && "bg-primary/50")}
|
||||||
"w-1 cursor-col-resize hover:bg-primary/50 transition-colors",
|
|
||||||
isResizing && "bg-primary/50"
|
|
||||||
)}
|
|
||||||
onMouseDown={handleResizeStart}
|
onMouseDown={handleResizeStart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 우측 패널 */}
|
{/* 우측 패널 */}
|
||||||
<div className="flex-1 flex flex-col bg-card">
|
<div className="bg-card flex flex-1 flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-4 border-b bg-muted/30">
|
<div className="bg-muted/30 border-b p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-semibold text-base">
|
<h3 className="text-base font-semibold">
|
||||||
{selectedLeftItem
|
{selectedLeftItem
|
||||||
? config.leftPanel?.displayColumns?.[0]
|
? config.leftPanel?.displayColumns?.[0]
|
||||||
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
|
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
|
||||||
: config.rightPanel?.title || "상세"
|
: config.rightPanel?.title || "상세"
|
||||||
: config.rightPanel?.title || "상세"}
|
: config.rightPanel?.title || "상세"}
|
||||||
</h3>
|
</h3>
|
||||||
{selectedLeftItem && (
|
{selectedLeftItem && <span className="text-muted-foreground text-sm">({rightData.length}건)</span>}
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
({rightData.length}건)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* 선택된 항목 수 표시 */}
|
{/* 선택된 항목 수 표시 */}
|
||||||
{selectedRightItems.size > 0 && (
|
{selectedRightItems.size > 0 && (
|
||||||
<span className="text-sm text-primary font-medium">
|
<span className="text-primary text-sm font-medium">{selectedRightItems.size}개 선택됨</span>
|
||||||
{selectedRightItems.size}개 선택됨
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1334,7 +1395,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 기존 단일 추가 버튼 (하위 호환성) */}
|
{/* 기존 단일 추가 버튼 (하위 호환성) */}
|
||||||
{config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
|
{config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
|
||||||
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
|
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
{config.rightPanel?.addButtonLabel || "추가"}
|
{config.rightPanel?.addButtonLabel || "추가"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1344,12 +1405,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
{config.rightPanel?.showSearch && selectedLeftItem && (
|
{config.rightPanel?.showSearch && selectedLeftItem && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={rightSearchTerm}
|
value={rightSearchTerm}
|
||||||
onChange={(e) => setRightSearchTerm(e.target.value)}
|
onChange={(e) => setRightSearchTerm(e.target.value)}
|
||||||
className="pl-9 h-9 text-sm"
|
className="h-9 pl-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1358,28 +1419,24 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{/* 내용 */}
|
{/* 내용 */}
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
{!selectedLeftItem ? (
|
{!selectedLeftItem ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center">
|
||||||
<Users className="h-16 w-16 mb-3 opacity-30" />
|
<Users className="mb-3 h-16 w-16 opacity-30" />
|
||||||
<span className="text-base">{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}</span>
|
<span className="text-base">{config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"}</span>
|
||||||
</div>
|
</div>
|
||||||
) : rightLoading ? (
|
) : rightLoading ? (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
|
<div className="text-muted-foreground flex h-full items-center justify-center text-base">로딩 중...</div>
|
||||||
로딩 중...
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
|
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
|
||||||
{config.rightPanel?.displayMode === "table" ? (
|
{config.rightPanel?.displayMode === "table" ? (
|
||||||
renderRightTable()
|
renderRightTable()
|
||||||
) : filteredRightData.length === 0 ? (
|
) : filteredRightData.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center">
|
||||||
<Users className="h-16 w-16 mb-3 opacity-30" />
|
<Users className="mb-3 h-16 w-16 opacity-30" />
|
||||||
<span className="text-base">등록된 항목이 없습니다</span>
|
<span className="text-base">등록된 항목이 없습니다</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>{filteredRightData.map((item, index) => renderRightCard(item, index))}</div>
|
||||||
{filteredRightData.map((item, index) => renderRightCard(item, index))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1395,8 +1452,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{isBulkDelete
|
{isBulkDelete
|
||||||
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
|
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
|
||||||
: "이 항목을 삭제하시겠습니까?"}
|
: "이 항목을 삭제하시겠습니까?"}
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -1420,4 +1476,3 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
export const SplitPanelLayout2Wrapper: React.FC<SplitPanelLayout2ComponentProps> = (props) => {
|
export const SplitPanelLayout2Wrapper: React.FC<SplitPanelLayout2ComponentProps> = (props) => {
|
||||||
return <SplitPanelLayout2Component {...props} />;
|
return <SplitPanelLayout2Component {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,9 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
SelectContent,
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -68,17 +51,17 @@ interface ScreenInfo {
|
||||||
screen_code: string;
|
screen_code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanelProps> = ({
|
export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanelProps> = ({ config, onChange }) => {
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
// updateConfig 헬퍼 함수: 경로 기반으로 config를 업데이트
|
// updateConfig 헬퍼 함수: 경로 기반으로 config를 업데이트
|
||||||
const updateConfig = useCallback((path: string, value: any) => {
|
const updateConfig = useCallback(
|
||||||
|
(path: string, value: any) => {
|
||||||
console.log(`[SplitPanelLayout2ConfigPanel] updateConfig: ${path} =`, value);
|
console.log(`[SplitPanelLayout2ConfigPanel] updateConfig: ${path} =`, value);
|
||||||
const newConfig = setPath(config, path, value);
|
const newConfig = setPath(config, path, value);
|
||||||
console.log("[SplitPanelLayout2ConfigPanel] newConfig:", newConfig);
|
console.log("[SplitPanelLayout2ConfigPanel] newConfig:", newConfig);
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
}, [config, onChange]);
|
},
|
||||||
|
[config, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
// 상태
|
// 상태
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
|
@ -253,7 +236,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
// 메인 테이블 컬럼 먼저 로드
|
// 메인 테이블 컬럼 먼저 로드
|
||||||
try {
|
try {
|
||||||
const mainResponse = await apiClient.get(`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`);
|
const mainResponse = await apiClient.get(
|
||||||
|
`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`,
|
||||||
|
);
|
||||||
let mainColumns: ColumnInfo[] = [];
|
let mainColumns: ColumnInfo[] = [];
|
||||||
|
|
||||||
if (mainResponse.data?.success) {
|
if (mainResponse.data?.success) {
|
||||||
|
|
@ -287,7 +272,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
...col,
|
...col,
|
||||||
// 유니크 키를 위해 테이블명_컬럼명 형태로 저장
|
// 유니크 키를 위해 테이블명_컬럼명 형태로 저장
|
||||||
column_name: `${jt.joinTable}.${col.column_name}`,
|
column_name: `${jt.joinTable}.${col.column_name}`,
|
||||||
column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`,
|
column_comment: col.column_comment
|
||||||
|
? `${col.column_comment} (${jt.joinTable})`
|
||||||
|
: `${col.column_name} (${jt.joinTable})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -300,7 +287,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
// 메인 + 조인 컬럼 합치기
|
// 메인 + 조인 컬럼 합치기
|
||||||
setRightColumns([...mainColumns, ...joinColumns]);
|
setRightColumns([...mainColumns, ...joinColumns]);
|
||||||
console.log(`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`);
|
console.log(
|
||||||
|
`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("조인 테이블 컬럼 로드 실패:", error);
|
console.error("조인 테이블 컬럼 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -354,15 +343,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check className={cn("mr-2 h-4 w-4", value === table.table_name ? "opacity-100" : "opacity-0")} />
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
value === table.table_name ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{table.table_comment || table.table_name}</span>
|
<span>{table.table_comment || table.table_name}</span>
|
||||||
<span className="text-xs text-muted-foreground">{table.table_name}</span>
|
<span className="text-muted-foreground text-xs">{table.table_name}</span>
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -392,7 +376,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={screensLoading}
|
disabled={screensLoading}
|
||||||
className="w-full justify-between h-9 text-sm"
|
className="h-9 w-full justify-between text-sm"
|
||||||
>
|
>
|
||||||
{screensLoading
|
{screensLoading
|
||||||
? "로딩 중..."
|
? "로딩 중..."
|
||||||
|
|
@ -424,16 +408,16 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
}}
|
}}
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
>
|
>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex w-full items-center">
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4 shrink-0",
|
"mr-2 h-4 w-4 shrink-0",
|
||||||
value === screen.screen_id ? "opacity-100" : "opacity-0"
|
value === screen.screen_id ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{screen.screen_name}</span>
|
<span>{screen.screen_name}</span>
|
||||||
<span className="text-xs text-muted-foreground">{screen.screen_code}</span>
|
<span className="text-muted-foreground text-xs">{screen.screen_code}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|
@ -457,9 +441,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
}> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => {
|
}> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => {
|
||||||
// 현재 선택된 값의 라벨 찾기
|
// 현재 선택된 값의 라벨 찾기
|
||||||
const selectedColumn = columns.find((col) => col.column_name === value);
|
const selectedColumn = columns.find((col) => col.column_name === value);
|
||||||
const displayValue = selectedColumn
|
const displayValue = selectedColumn ? selectedColumn.column_comment || selectedColumn.column_name : value || "";
|
||||||
? selectedColumn.column_comment || selectedColumn.column_name
|
|
||||||
: value || "";
|
|
||||||
|
|
||||||
// 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블)
|
// 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블)
|
||||||
const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")");
|
const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")");
|
||||||
|
|
@ -476,10 +458,8 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onValueChange}>
|
<Select value={value || ""} onValueChange={onValueChange}>
|
||||||
<SelectTrigger className="h-9 text-sm min-w-[120px]">
|
<SelectTrigger className="h-9 min-w-[120px] text-sm">
|
||||||
<SelectValue placeholder={placeholder}>
|
<SelectValue placeholder={placeholder}>{displayValue || placeholder}</SelectValue>
|
||||||
{displayValue || placeholder}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{columns.length === 0 ? (
|
{columns.length === 0 ? (
|
||||||
|
|
@ -492,10 +472,8 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{col.column_comment || col.column_name}</span>
|
<span>{col.column_comment || col.column_name}</span>
|
||||||
{showTableName && (
|
{showTableName && (
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{isJoinColumn(col)
|
{isJoinColumn(col) ? col.column_name : `${col.column_name} (${tableName || "메인"})`}
|
||||||
? col.column_name
|
|
||||||
: `${col.column_name} (${tableName || "메인"})`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -554,9 +532,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const selectedTable = tables.find((t) => t.table_name === joinTable.joinTable);
|
const selectedTable = tables.find((t) => t.table_name === joinTable.joinTable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border p-3 space-y-3">
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">조인 {index + 1}</span>
|
<span className="text-muted-foreground text-xs font-medium">조인 {index + 1}</span>
|
||||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onRemove}>
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onRemove}>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -603,12 +581,12 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-3 w-3",
|
"mr-2 h-3 w-3",
|
||||||
joinTable.joinTable === table.table_name ? "opacity-100" : "opacity-0"
|
joinTable.joinTable === table.table_name ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{table.table_comment || table.table_name}</span>
|
<span>{table.table_comment || table.table_name}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{table.table_name}</span>
|
<span className="text-muted-foreground text-[10px]">{table.table_name}</span>
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -622,10 +600,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
{/* 조인 타입 */}
|
{/* 조인 타입 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">조인 방식</Label>
|
<Label className="text-xs">조인 방식</Label>
|
||||||
<Select
|
<Select value={joinTable.joinType || "LEFT"} onValueChange={(value) => onUpdate("joinType", value)}>
|
||||||
value={joinTable.joinType || "LEFT"}
|
|
||||||
onValueChange={(value) => onUpdate("joinType", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -639,9 +614,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
{/* 조인 조건 */}
|
{/* 조인 조건 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">조인 조건</Label>
|
<Label className="text-xs">조인 조건</Label>
|
||||||
<div className="rounded-md bg-muted/30 p-2 space-y-2">
|
<div className="bg-muted/30 space-y-2 rounded-md p-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">메인 테이블 컬럼</Label>
|
<Label className="text-muted-foreground text-[10px]">메인 테이블 컬럼</Label>
|
||||||
<ColumnSelect
|
<ColumnSelect
|
||||||
columns={mainTableColumns}
|
columns={mainTableColumns}
|
||||||
value={joinTable.mainColumn || ""}
|
value={joinTable.mainColumn || ""}
|
||||||
|
|
@ -649,9 +624,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
placeholder="메인 테이블 컬럼"
|
placeholder="메인 테이블 컬럼"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-[10px] text-muted-foreground">=</div>
|
<div className="text-muted-foreground text-center text-[10px]">=</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">조인 테이블 컬럼</Label>
|
<Label className="text-muted-foreground text-[10px]">조인 테이블 컬럼</Label>
|
||||||
<ColumnSelect
|
<ColumnSelect
|
||||||
columns={joinTableColumns}
|
columns={joinTableColumns}
|
||||||
value={joinTable.joinColumn || ""}
|
value={joinTable.joinColumn || ""}
|
||||||
|
|
@ -664,12 +639,12 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 가져올 컬럼 선택 */}
|
{/* 가져올 컬럼 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<Label className="text-xs">가져올 컬럼</Label>
|
<Label className="text-xs">가져올 컬럼</Label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-5 text-[10px] px-1"
|
className="h-5 px-1 text-[10px]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const current = joinTable.selectColumns || [];
|
const current = joinTable.selectColumns || [];
|
||||||
onUpdate("selectColumns", [...current, ""]);
|
onUpdate("selectColumns", [...current, ""]);
|
||||||
|
|
@ -680,9 +655,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2 text-[10px]">조인 테이블에서 표시할 컬럼들을 선택하세요</p>
|
||||||
조인 테이블에서 표시할 컬럼들을 선택하세요
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{(joinTable.selectColumns || []).map((col, colIndex) => (
|
{(joinTable.selectColumns || []).map((col, colIndex) => (
|
||||||
<div key={colIndex} className="flex items-center gap-1">
|
<div key={colIndex} className="flex items-center gap-1">
|
||||||
|
|
@ -704,7 +677,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const current = joinTable.selectColumns || [];
|
const current = joinTable.selectColumns || [];
|
||||||
onUpdate(
|
onUpdate(
|
||||||
"selectColumns",
|
"selectColumns",
|
||||||
current.filter((_, i) => i !== colIndex)
|
current.filter((_, i) => i !== colIndex),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -713,7 +686,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(joinTable.selectColumns || []).length === 0 && (
|
{(joinTable.selectColumns || []).length === 0 && (
|
||||||
<div className="rounded border py-2 text-center text-[10px] text-muted-foreground">
|
<div className="text-muted-foreground rounded border py-2 text-center text-[10px]">
|
||||||
가져올 컬럼을 추가하세요
|
가져올 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -726,14 +699,11 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
// 표시 컬럼 추가
|
// 표시 컬럼 추가
|
||||||
const addDisplayColumn = (side: "left" | "right") => {
|
const addDisplayColumn = (side: "left" | "right") => {
|
||||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||||
const currentColumns = side === "left"
|
const currentColumns =
|
||||||
? config.leftPanel?.displayColumns || []
|
side === "left" ? config.leftPanel?.displayColumns || [] : config.rightPanel?.displayColumns || [];
|
||||||
: config.rightPanel?.displayColumns || [];
|
|
||||||
|
|
||||||
// 기본 테이블 설정 (메인 테이블)
|
// 기본 테이블 설정 (메인 테이블)
|
||||||
const defaultTable = side === "left"
|
const defaultTable = side === "left" ? config.leftPanel?.tableName : config.rightPanel?.tableName;
|
||||||
? config.leftPanel?.tableName
|
|
||||||
: config.rightPanel?.tableName;
|
|
||||||
|
|
||||||
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
|
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
|
||||||
};
|
};
|
||||||
|
|
@ -741,11 +711,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
// 표시 컬럼 삭제
|
// 표시 컬럼 삭제
|
||||||
const removeDisplayColumn = (side: "left" | "right", index: number) => {
|
const removeDisplayColumn = (side: "left" | "right", index: number) => {
|
||||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||||
const currentColumns = side === "left"
|
const currentColumns =
|
||||||
? config.leftPanel?.displayColumns || []
|
side === "left" ? config.leftPanel?.displayColumns || [] : config.rightPanel?.displayColumns || [];
|
||||||
: config.rightPanel?.displayColumns || [];
|
|
||||||
|
|
||||||
updateConfig(path, currentColumns.filter((_, i) => i !== index));
|
updateConfig(
|
||||||
|
path,
|
||||||
|
currentColumns.filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 표시 컬럼 업데이트
|
// 표시 컬럼 업데이트
|
||||||
|
|
@ -753,12 +725,11 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
index: number,
|
index: number,
|
||||||
fieldOrPartial: keyof ColumnConfig | Partial<ColumnConfig>,
|
fieldOrPartial: keyof ColumnConfig | Partial<ColumnConfig>,
|
||||||
value?: any
|
value?: any,
|
||||||
) => {
|
) => {
|
||||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||||
const currentColumns = side === "left"
|
const currentColumns =
|
||||||
? [...(config.leftPanel?.displayColumns || [])]
|
side === "left" ? [...(config.leftPanel?.displayColumns || [])] : [...(config.rightPanel?.displayColumns || [])];
|
||||||
: [...(config.rightPanel?.displayColumns || [])];
|
|
||||||
|
|
||||||
if (currentColumns[index]) {
|
if (currentColumns[index]) {
|
||||||
if (typeof fieldOrPartial === "object") {
|
if (typeof fieldOrPartial === "object") {
|
||||||
|
|
@ -781,7 +752,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
// 데이터 전달 필드 삭제
|
// 데이터 전달 필드 삭제
|
||||||
const removeDataTransferField = (index: number) => {
|
const removeDataTransferField = (index: number) => {
|
||||||
const currentFields = config.dataTransferFields || [];
|
const currentFields = config.dataTransferFields || [];
|
||||||
updateConfig("dataTransferFields", currentFields.filter((_, i) => i !== index));
|
updateConfig(
|
||||||
|
"dataTransferFields",
|
||||||
|
currentFields.filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 데이터 전달 필드 업데이트
|
// 데이터 전달 필드 업데이트
|
||||||
|
|
@ -797,7 +771,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<div className="space-y-6 p-1">
|
<div className="space-y-6 p-1">
|
||||||
{/* 좌측 패널 설정 */}
|
{/* 좌측 패널 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-sm border-b pb-2">좌측 패널 설정 (마스터)</h4>
|
<h4 className="border-b pb-2 text-sm font-medium">좌측 패널 설정 (마스터)</h4>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -823,7 +797,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 표시 컬럼 */}
|
{/* 표시 컬럼 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Label className="text-xs">표시할 컬럼</Label>
|
<Label className="text-xs">표시할 컬럼</Label>
|
||||||
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => addDisplayColumn("left")}>
|
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => addDisplayColumn("left")}>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
|
@ -834,7 +808,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
{(config.leftPanel?.displayColumns || []).map((col, index) => (
|
{(config.leftPanel?.displayColumns || []).map((col, index) => (
|
||||||
<div key={index} className="space-y-2 rounded-md border p-3">
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
<span className="text-muted-foreground text-xs font-medium">컬럼 {index + 1}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -851,7 +825,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
placeholder="컬럼 선택"
|
placeholder="컬럼 선택"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">표시 라벨</Label>
|
<Label className="text-muted-foreground text-xs">표시 라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={col.label || ""}
|
value={col.label || ""}
|
||||||
onChange={(e) => updateDisplayColumn("left", index, "label", e.target.value)}
|
onChange={(e) => updateDisplayColumn("left", index, "label", e.target.value)}
|
||||||
|
|
@ -860,7 +834,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
<Label className="text-muted-foreground text-xs">표시 위치</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.displayRow || "name"}
|
value={col.displayRow || "name"}
|
||||||
onValueChange={(value) => updateDisplayColumn("left", index, "displayRow", value)}
|
onValueChange={(value) => updateDisplayColumn("left", index, "displayRow", value)}
|
||||||
|
|
@ -877,7 +851,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(config.leftPanel?.displayColumns || []).length === 0 && (
|
{(config.leftPanel?.displayColumns || []).length === 0 && (
|
||||||
<div className="rounded-md border py-4 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground rounded-md border py-4 text-center text-xs">
|
||||||
표시할 컬럼을 추가하세요
|
표시할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -930,7 +904,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const current = config.leftPanel?.searchColumns || [];
|
const current = config.leftPanel?.searchColumns || [];
|
||||||
updateConfig(
|
updateConfig(
|
||||||
"leftPanel.searchColumns",
|
"leftPanel.searchColumns",
|
||||||
current.filter((_, i) => i !== index)
|
current.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -939,7 +913,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(config.leftPanel?.searchColumns || []).length === 0 && (
|
{(config.leftPanel?.searchColumns || []).length === 0 && (
|
||||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground rounded-md border py-3 text-center text-xs">
|
||||||
검색할 컬럼을 추가하세요
|
검색할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -983,7 +957,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 우측 패널 설정 */}
|
{/* 우측 패널 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-sm border-b pb-2">우측 패널 설정 (상세)</h4>
|
<h4 className="border-b pb-2 text-sm font-medium">우측 패널 설정 (상세)</h4>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1033,7 +1007,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다.
|
다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -1059,7 +1033,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const current = config.rightPanel?.joinTables || [];
|
const current = config.rightPanel?.joinTables || [];
|
||||||
updateConfig(
|
updateConfig(
|
||||||
"rightPanel.joinTables",
|
"rightPanel.joinTables",
|
||||||
current.filter((_, i) => i !== index)
|
current.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1069,14 +1043,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 표시 컬럼 */}
|
{/* 표시 컬럼 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Label className="text-xs">표시할 컬럼</Label>
|
<Label className="text-xs">표시할 컬럼</Label>
|
||||||
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => addDisplayColumn("right")}>
|
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => addDisplayColumn("right")}>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2 text-[10px]">
|
||||||
테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요.
|
테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -1109,9 +1083,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
<span className="text-muted-foreground text-xs font-medium">컬럼 {index + 1}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1124,7 +1098,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
{/* 테이블 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">테이블</Label>
|
<Label className="text-muted-foreground text-[10px]">테이블</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.sourceTable || config.rightPanel?.tableName || ""}
|
value={col.sourceTable || config.rightPanel?.tableName || ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -1143,7 +1117,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<SelectItem key={tableName} value={tableName}>
|
<SelectItem key={tableName} value={tableName}>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{getTableLabel(tableName)}</span>
|
<span>{getTableLabel(tableName)}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{tableName}</span>
|
<span className="text-muted-foreground text-[10px]">{tableName}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1153,7 +1127,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 컬럼 선택 */}
|
{/* 컬럼 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
<Label className="text-muted-foreground text-[10px]">컬럼</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.name || ""}
|
value={col.name || ""}
|
||||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||||
|
|
@ -1178,7 +1152,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<SelectItem key={c.column_name} value={c.column_name}>
|
<SelectItem key={c.column_name} value={c.column_name}>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{displayLabel}</span>
|
<span>{displayLabel}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{actualColumnName}</span>
|
<span className="text-muted-foreground text-[10px]">{actualColumnName}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -1190,7 +1164,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 표시 라벨 */}
|
{/* 표시 라벨 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">표시 라벨</Label>
|
<Label className="text-muted-foreground text-[10px]">표시 라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={col.label || ""}
|
value={col.label || ""}
|
||||||
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
||||||
|
|
@ -1201,7 +1175,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 표시 위치 */}
|
{/* 표시 위치 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">표시 위치</Label>
|
<Label className="text-muted-foreground text-[10px]">표시 위치</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.displayRow || "info"}
|
value={col.displayRow || "info"}
|
||||||
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
||||||
|
|
@ -1219,7 +1193,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||||
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
|
<div className="text-muted-foreground rounded-md border py-4 text-center text-xs">
|
||||||
표시할 컬럼을 추가하세요
|
표시할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1252,9 +1226,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2 text-[10px]">표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.</p>
|
||||||
표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
|
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
|
||||||
// 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
|
// 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
|
||||||
|
|
@ -1266,11 +1238,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
// 현재 선택된 컬럼의 표시 정보
|
// 현재 선택된 컬럼의 표시 정보
|
||||||
const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
|
const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
|
||||||
const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
|
const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
|
||||||
const selectedLabel = selectedDisplayCol?.label ||
|
const selectedLabel =
|
||||||
|
selectedDisplayCol?.label ||
|
||||||
selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
|
selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
|
||||||
searchCol.columnName;
|
searchCol.columnName;
|
||||||
const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
|
const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
|
||||||
const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
|
const selectedTableLabel =
|
||||||
|
tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
|
@ -1282,12 +1256,12 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
updateConfig("rightPanel.searchColumns", current);
|
updateConfig("rightPanel.searchColumns", current);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 text-xs flex-1">
|
<SelectTrigger className="h-9 flex-1 text-xs">
|
||||||
<SelectValue placeholder="컬럼 선택">
|
<SelectValue placeholder="컬럼 선택">
|
||||||
{searchCol.columnName ? (
|
{searchCol.columnName ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span>{selectedLabel}</span>
|
<span>{selectedLabel}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">({selectedTableLabel})</span>
|
<span className="text-muted-foreground text-[10px]">({selectedTableLabel})</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"컬럼 선택"
|
"컬럼 선택"
|
||||||
|
|
@ -1302,9 +1276,11 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
) : (
|
) : (
|
||||||
validDisplayColumns.map((dc, dcIndex) => {
|
validDisplayColumns.map((dc, dcIndex) => {
|
||||||
const colInfo = rightColumns.find((c) => c.column_name === dc.name);
|
const colInfo = rightColumns.find((c) => c.column_name === dc.name);
|
||||||
const label = dc.label || colInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || dc.name;
|
const label =
|
||||||
|
dc.label || colInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || dc.name;
|
||||||
const tableName = dc.sourceTable || config.rightPanel?.tableName || "";
|
const tableName = dc.sourceTable || config.rightPanel?.tableName || "";
|
||||||
const tableLabel = tables.find((t) => t.table_name === tableName)?.table_comment || tableName;
|
const tableLabel =
|
||||||
|
tables.find((t) => t.table_name === tableName)?.table_comment || tableName;
|
||||||
const actualColName = dc.name.includes(".") ? dc.name.split(".")[1] : dc.name;
|
const actualColName = dc.name.includes(".") ? dc.name.split(".")[1] : dc.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1312,9 +1288,9 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">({tableLabel})</span>
|
<span className="text-muted-foreground text-[10px]">({tableLabel})</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{actualColName}</span>
|
<span className="text-muted-foreground text-[10px]">{actualColName}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -1330,7 +1306,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const current = config.rightPanel?.searchColumns || [];
|
const current = config.rightPanel?.searchColumns || [];
|
||||||
updateConfig(
|
updateConfig(
|
||||||
"rightPanel.searchColumns",
|
"rightPanel.searchColumns",
|
||||||
current.filter((_, i) => i !== index)
|
current.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1340,12 +1316,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground rounded-md border py-3 text-center text-xs">
|
||||||
먼저 표시할 컬럼을 추가하세요
|
먼저 표시할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
|
{(config.rightPanel?.displayColumns || []).length > 0 &&
|
||||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
(config.rightPanel?.searchColumns || []).length === 0 && (
|
||||||
|
<div className="text-muted-foreground rounded-md border py-3 text-center text-xs">
|
||||||
검색할 컬럼을 추가하세요
|
검색할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1386,13 +1363,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 표시 모드 설정 */}
|
{/* 표시 모드 설정 */}
|
||||||
<div className="pt-3 border-t">
|
<div className="border-t pt-3">
|
||||||
<Label className="text-xs font-medium">표시 모드</Label>
|
<Label className="text-xs font-medium">표시 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.rightPanel?.displayMode || "card"}
|
value={config.rightPanel?.displayMode || "card"}
|
||||||
onValueChange={(value) => updateConfig("rightPanel.displayMode", value)}
|
onValueChange={(value) => updateConfig("rightPanel.displayMode", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 text-sm mt-1">
|
<SelectTrigger className="mt-1 h-9 text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -1400,7 +1377,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<SelectItem value="table">테이블형</SelectItem>
|
<SelectItem value="table">테이블형</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시
|
카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1410,7 +1387,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">라벨 표시</Label>
|
<Label className="text-xs">라벨 표시</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">라벨: 값 형식으로 표시</p>
|
<p className="text-muted-foreground text-[10px]">라벨: 값 형식으로 표시</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.rightPanel?.showLabels || false}
|
checked={config.rightPanel?.showLabels || false}
|
||||||
|
|
@ -1423,7 +1400,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">체크박스 표시</Label>
|
<Label className="text-xs">체크박스 표시</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">항목 선택 기능 활성화</p>
|
<p className="text-muted-foreground text-[10px]">항목 선택 기능 활성화</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.rightPanel?.showCheckbox || false}
|
checked={config.rightPanel?.showCheckbox || false}
|
||||||
|
|
@ -1432,7 +1409,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 수정/삭제 버튼 */}
|
{/* 수정/삭제 버튼 */}
|
||||||
<div className="pt-3 border-t">
|
<div className="border-t pt-3">
|
||||||
<Label className="text-xs font-medium">개별 수정/삭제</Label>
|
<Label className="text-xs font-medium">개별 수정/삭제</Label>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -1463,9 +1440,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
open={false}
|
open={false}
|
||||||
onOpenChange={() => {}}
|
onOpenChange={() => {}}
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-[10px]">미선택 시 추가 모달 화면을 수정용으로 사용</p>
|
||||||
미선택 시 추가 모달 화면을 수정용으로 사용
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1478,14 +1453,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
|
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
|
||||||
placeholder="기본키 컬럼 선택 (기본: id)"
|
placeholder="기본키 컬럼 선택 (기본: id)"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용)
|
수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 복수 액션 버튼 설정 */}
|
{/* 복수 액션 버튼 설정 */}
|
||||||
<div className="pt-3 border-t">
|
<div className="border-t pt-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium">액션 버튼 (복수)</Label>
|
<Label className="text-xs font-medium">액션 버튼 (복수)</Label>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1508,14 +1483,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2 text-[10px]">
|
||||||
복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다
|
복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(config.rightPanel?.actionButtons || []).map((btn, index) => (
|
{(config.rightPanel?.actionButtons || []).map((btn, index) => (
|
||||||
<div key={btn.id} className="rounded-md border p-3 space-y-2">
|
<div key={btn.id} className="space-y-2 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">버튼 {index + 1}</span>
|
<span className="text-muted-foreground text-xs font-medium">버튼 {index + 1}</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1524,7 +1499,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const current = config.rightPanel?.actionButtons || [];
|
const current = config.rightPanel?.actionButtons || [];
|
||||||
updateConfig(
|
updateConfig(
|
||||||
"rightPanel.actionButtons",
|
"rightPanel.actionButtons",
|
||||||
current.filter((_, i) => i !== index)
|
current.filter((_, i) => i !== index),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1532,7 +1507,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">버튼 라벨</Label>
|
<Label className="text-muted-foreground text-xs">버튼 라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={btn.label}
|
value={btn.label}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -1545,7 +1520,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">동작</Label>
|
<Label className="text-muted-foreground text-xs">동작</Label>
|
||||||
<Select
|
<Select
|
||||||
value={btn.action || "add"}
|
value={btn.action || "add"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -1566,7 +1541,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">스타일</Label>
|
<Label className="text-muted-foreground text-xs">스타일</Label>
|
||||||
<Select
|
<Select
|
||||||
value={btn.variant || "default"}
|
value={btn.variant || "default"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -1587,7 +1562,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">아이콘</Label>
|
<Label className="text-muted-foreground text-xs">아이콘</Label>
|
||||||
<Select
|
<Select
|
||||||
value={btn.icon || "none"}
|
value={btn.icon || "none"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -1609,7 +1584,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
{btn.action === "add" && (
|
{btn.action === "add" && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">모달 화면</Label>
|
<Label className="text-muted-foreground text-xs">모달 화면</Label>
|
||||||
<ScreenSelect
|
<ScreenSelect
|
||||||
value={btn.modalScreenId}
|
value={btn.modalScreenId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
|
@ -1626,7 +1601,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(config.rightPanel?.actionButtons || []).length === 0 && (
|
{(config.rightPanel?.actionButtons || []).length === 0 && (
|
||||||
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
|
<div className="text-muted-foreground rounded-md border py-4 text-center text-xs">
|
||||||
액션 버튼을 추가하세요 (선택사항)
|
액션 버튼을 추가하세요 (선택사항)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1637,16 +1612,92 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 연결 설정 */}
|
{/* 연결 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="border-b pb-2 text-sm font-medium">연결 설정 (조인)</h4>
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<h4 className="text-sm font-medium">연결 설정 (조인)</h4>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
const currentKeys = config.joinConfig?.keys || [];
|
||||||
|
// 단일키에서 복합키로 전환 시 기존 값 유지
|
||||||
|
if (currentKeys.length === 0 && config.joinConfig?.leftColumn && config.joinConfig?.rightColumn) {
|
||||||
|
updateConfig("joinConfig.keys", [
|
||||||
|
{ leftColumn: config.joinConfig.leftColumn, rightColumn: config.joinConfig.rightColumn },
|
||||||
|
{ leftColumn: "", rightColumn: "" },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
updateConfig("joinConfig.keys", [...currentKeys, { leftColumn: "", rightColumn: "" }]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
조인 키 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
<div className="bg-muted/50 text-muted-foreground rounded-md p-3 text-xs">
|
||||||
<p className="mb-1 font-medium text-foreground">좌측 패널 선택 시 우측 패널 데이터 필터링</p>
|
<p className="text-foreground mb-1 font-medium">좌측 패널 선택 시 우측 패널 데이터 필터링</p>
|
||||||
<p>좌측에서 항목을 선택하면 좌측 조인 컬럼의 값으로 우측 테이블을 필터링합니다.</p>
|
<p>좌측에서 항목을 선택하면 좌측 조인 컬럼의 값으로 우측 테이블을 필터링합니다.</p>
|
||||||
<p className="mt-1 text-[10px]">예: 부서(dept_code) 선택 시 해당 부서의 사원만 표시</p>
|
<p className="mt-1 text-[10px]">예: 부서(dept_code) 선택 시 해당 부서의 사원만 표시</p>
|
||||||
|
<p className="mt-1 text-[10px] text-blue-600">복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* 복합키가 설정된 경우 */}
|
||||||
|
{(config.joinConfig?.keys || []).length > 0 ? (
|
||||||
|
<>
|
||||||
|
{(config.joinConfig?.keys || []).map((key, index) => (
|
||||||
|
<div key={index} className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">조인 키 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive h-6 w-6 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newKeys = (config.joinConfig?.keys || []).filter((_, i) => i !== index);
|
||||||
|
updateConfig("joinConfig.keys", newKeys);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌측 컬럼</Label>
|
||||||
|
<ColumnSelect
|
||||||
|
columns={leftColumns}
|
||||||
|
value={key.leftColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newKeys = [...(config.joinConfig?.keys || [])];
|
||||||
|
newKeys[index] = { ...newKeys[index], leftColumn: value };
|
||||||
|
updateConfig("joinConfig.keys", newKeys);
|
||||||
|
}}
|
||||||
|
placeholder="좌측 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">우측 컬럼</Label>
|
||||||
|
<ColumnSelect
|
||||||
|
columns={rightColumns}
|
||||||
|
value={key.rightColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newKeys = [...(config.joinConfig?.keys || [])];
|
||||||
|
newKeys[index] = { ...newKeys[index], rightColumn: value };
|
||||||
|
updateConfig("joinConfig.keys", newKeys);
|
||||||
|
}}
|
||||||
|
placeholder="우측 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 단일키 (하위 호환성) */
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">좌측 테이블 조인 컬럼</Label>
|
<Label className="text-xs">좌측 테이블 조인 컬럼</Label>
|
||||||
<ColumnSelect
|
<ColumnSelect
|
||||||
|
|
@ -1666,6 +1717,8 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
placeholder="조인 컬럼 선택"
|
placeholder="조인 컬럼 선택"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1680,8 +1733,8 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설명 */}
|
{/* 설명 */}
|
||||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
<div className="bg-muted/50 text-muted-foreground rounded-md p-3 text-xs">
|
||||||
<p className="mb-1 font-medium text-foreground">우측 패널 추가 버튼 클릭 시 모달로 데이터 전달</p>
|
<p className="text-foreground mb-1 font-medium">우측 패널 추가 버튼 클릭 시 모달로 데이터 전달</p>
|
||||||
<p>좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.</p>
|
<p>좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.</p>
|
||||||
<p className="mt-1 text-[10px]">예: dept_code를 모달의 dept_code 필드에 자동 입력</p>
|
<p className="mt-1 text-[10px]">예: dept_code를 모달의 dept_code 필드에 자동 입력</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1721,7 +1774,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(config.dataTransferFields || []).length === 0 && (
|
{(config.dataTransferFields || []).length === 0 && (
|
||||||
<div className="rounded-md border py-4 text-center text-xs text-muted-foreground">
|
<div className="text-muted-foreground rounded-md border py-4 text-center text-xs">
|
||||||
전달할 필드를 추가하세요
|
전달할 필드를 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1730,7 +1783,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-sm border-b pb-2">레이아웃 설정</h4>
|
<h4 className="border-b pb-2 text-sm font-medium">레이아웃 설정</h4>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,21 @@ export interface RightPanelConfig {
|
||||||
joinTables?: JoinTableConfig[];
|
joinTables?: JoinTableConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조인 키 설정 (복합키 지원)
|
||||||
|
*/
|
||||||
|
export interface JoinKey {
|
||||||
|
leftColumn: string; // 좌측 테이블의 조인 컬럼
|
||||||
|
rightColumn: string; // 우측 테이블의 조인 컬럼
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 설정
|
* 조인 설정
|
||||||
*/
|
*/
|
||||||
export interface JoinConfig {
|
export interface JoinConfig {
|
||||||
leftColumn: string; // 좌측 테이블의 조인 컬럼
|
leftColumn?: string; // 좌측 테이블의 조인 컬럼 (단일키 - 하위 호환성)
|
||||||
rightColumn: string; // 우측 테이블의 조인 컬럼
|
rightColumn?: string; // 우측 테이블의 조인 컬럼 (단일키 - 하위 호환성)
|
||||||
|
keys?: JoinKey[]; // 복합키 지원 (여러 컬럼으로 조인)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react";
|
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||||
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UniversalFormModalComponentProps,
|
UniversalFormModalComponentProps,
|
||||||
|
|
@ -36,6 +38,79 @@ import {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig, generateUniqueId } from "./config";
|
import { defaultConfig, generateUniqueId } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||||
|
*/
|
||||||
|
interface CascadingSelectFieldProps {
|
||||||
|
fieldId: string;
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
fieldId,
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const { options, loading } = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||||
|
<SelectTrigger id={fieldId} className="w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 범용 폼 모달 컴포넌트
|
* 범용 폼 모달 컴포넌트
|
||||||
*
|
*
|
||||||
|
|
@ -508,10 +583,7 @@ export function UniversalFormModalComponent({
|
||||||
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
|
// 저장 시점 채번규칙 처리 (allocateNumberingCode로 실제 순번 증가)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
if (
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
field.numberingRule?.enabled &&
|
|
||||||
field.numberingRule?.ruleId
|
|
||||||
) {
|
|
||||||
// generateOnSave: 저장 시 새로 생성
|
// generateOnSave: 저장 시 새로 생성
|
||||||
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
|
// generateOnOpen: 열 때 미리보기로 표시했지만, 저장 시 실제 순번 할당 필요
|
||||||
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
|
if (field.numberingRule.generateOnSave && !dataToSave[field.columnName]) {
|
||||||
|
|
@ -674,10 +746,7 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
for (const field of section.fields) {
|
for (const field of section.fields) {
|
||||||
// 채번규칙이 활성화된 필드 처리
|
// 채번규칙이 활성화된 필드 처리
|
||||||
if (
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
field.numberingRule?.enabled &&
|
|
||||||
field.numberingRule?.ruleId
|
|
||||||
) {
|
|
||||||
// 신규 생성이거나 값이 없는 경우에만 채번
|
// 신규 생성이거나 값이 없는 경우에만 채번
|
||||||
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
|
const isNewRecord = !initialData?.[multiTable.mainTable.primaryKeyColumn];
|
||||||
const hasNoValue = !mainData[field.columnName];
|
const hasNoValue = !mainData[field.columnName];
|
||||||
|
|
@ -766,7 +835,7 @@ export function UniversalFormModalComponent({
|
||||||
else {
|
else {
|
||||||
config.sections.forEach((section) => {
|
config.sections.forEach((section) => {
|
||||||
if (section.repeatable) return;
|
if (section.repeatable) return;
|
||||||
const matchingField = section.fields.find(f => f.columnName === mapping.targetColumn);
|
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
|
||||||
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
||||||
mainFieldMappings!.push({
|
mainFieldMappings!.push({
|
||||||
formField: matchingField.columnName,
|
formField: matchingField.columnName,
|
||||||
|
|
@ -779,8 +848,8 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 제거
|
// 중복 제거
|
||||||
mainFieldMappings = mainFieldMappings.filter((m, idx, arr) =>
|
mainFieldMappings = mainFieldMappings.filter(
|
||||||
arr.findIndex(x => x.targetColumn === m.targetColumn) === idx
|
(m, idx, arr) => arr.findIndex((x) => x.targetColumn === m.targetColumn) === idx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -833,7 +902,8 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = customApiSave.customMethod || "POST";
|
const method = customApiSave.customMethod || "POST";
|
||||||
const response = method === "PUT"
|
const response =
|
||||||
|
method === "PUT"
|
||||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||||
|
|
||||||
|
|
@ -913,7 +983,16 @@ export function UniversalFormModalComponent({
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]);
|
}, [
|
||||||
|
config,
|
||||||
|
formData,
|
||||||
|
repeatSections,
|
||||||
|
onSave,
|
||||||
|
validateRequiredFields,
|
||||||
|
saveSingleRow,
|
||||||
|
saveMultipleRows,
|
||||||
|
saveWithCustomApi,
|
||||||
|
]);
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
|
|
@ -962,9 +1041,32 @@ export function UniversalFormModalComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select": {
|
case "select": {
|
||||||
|
// 🆕 연쇄 드롭다운 처리
|
||||||
|
if (field.cascading?.enabled) {
|
||||||
|
const cascadingConfig = field.cascading;
|
||||||
|
const parentValue = formData[cascadingConfig.parentField];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CascadingSelectField
|
||||||
|
fieldId={fieldKey}
|
||||||
|
config={cascadingConfig as CascadingDropdownConfig}
|
||||||
|
parentValue={parentValue}
|
||||||
|
value={value}
|
||||||
|
onChange={onChangeHandler}
|
||||||
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 다중 컬럼 저장이 활성화된 경우
|
// 다중 컬럼 저장이 활성화된 경우
|
||||||
const lfgMappings = field.linkedFieldGroup?.mappings;
|
const lfgMappings = field.linkedFieldGroup?.mappings;
|
||||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
if (
|
||||||
|
field.linkedFieldGroup?.enabled &&
|
||||||
|
field.linkedFieldGroup?.sourceTable &&
|
||||||
|
lfgMappings &&
|
||||||
|
lfgMappings.length > 0
|
||||||
|
) {
|
||||||
const lfg = field.linkedFieldGroup;
|
const lfg = field.linkedFieldGroup;
|
||||||
const sourceTableName = lfg.sourceTable as string;
|
const sourceTableName = lfg.sourceTable as string;
|
||||||
const cachedData = linkedFieldDataCache[sourceTableName];
|
const cachedData = linkedFieldDataCache[sourceTableName];
|
||||||
|
|
@ -1016,7 +1118,7 @@ export function UniversalFormModalComponent({
|
||||||
const newItems = items.map((item) =>
|
const newItems = items.map((item) =>
|
||||||
item._id === repeatContext.itemId
|
item._id === repeatContext.itemId
|
||||||
? { ...item, [mapping.targetColumn]: mappedValue }
|
? { ...item, [mapping.targetColumn]: mappedValue }
|
||||||
: item
|
: item,
|
||||||
);
|
);
|
||||||
return { ...prev, [repeatContext.sectionId]: newItems };
|
return { ...prev, [repeatContext.sectionId]: newItems };
|
||||||
});
|
});
|
||||||
|
|
@ -1038,10 +1140,7 @@ export function UniversalFormModalComponent({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sourceData.length > 0 ? (
|
{sourceData.length > 0 ? (
|
||||||
sourceData.map((row, index) => (
|
sourceData.map((row, index) => (
|
||||||
<SelectItem
|
<SelectItem key={`${row[valueColumn] || index}_${index}`} value={String(row[valueColumn] || "")}>
|
||||||
key={`${row[valueColumn] || index}_${index}`}
|
|
||||||
value={String(row[valueColumn] || "")}
|
|
||||||
>
|
|
||||||
{getDisplayText(row)}
|
{getDisplayText(row)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,21 @@ export interface FormFieldConfig {
|
||||||
action: "filter" | "setValue" | "clear";
|
action: "filter" | "setValue" | "clear";
|
||||||
config?: any;
|
config?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 연쇄 드롭다운 설정 (부모 필드에 따른 동적 옵션)
|
||||||
|
cascading?: {
|
||||||
|
enabled: boolean;
|
||||||
|
parentField: string; // 부모 필드명
|
||||||
|
sourceTable: string; // 옵션을 조회할 테이블
|
||||||
|
parentKeyColumn: string; // 부모 값과 매칭할 컬럼
|
||||||
|
valueColumn: string; // 드롭다운 value로 사용할 컬럼
|
||||||
|
labelColumn: string; // 드롭다운 label로 표시할 컬럼
|
||||||
|
additionalFilters?: Record<string, unknown>;
|
||||||
|
emptyParentMessage?: string;
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
clearOnParentChange?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 연동 필드 매핑 설정
|
// 연동 필드 매핑 설정
|
||||||
|
|
|
||||||
|
|
@ -292,9 +292,10 @@ export class EnhancedFormService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시스템 필드 자동 추가
|
// 시스템 필드 자동 추가
|
||||||
const now = new Date().toISOString();
|
// created_date는 백엔드에서 처리하도록 프론트엔드에서 제거
|
||||||
if (!transformed.created_date && tableColumns.some((col) => col.columnName === "created_date")) {
|
// (기존 데이터 조회 시 포함된 created_date가 그대로 전송되는 문제 방지)
|
||||||
transformed.created_date = now;
|
if (tableColumns.some((col) => col.columnName === "created_date")) {
|
||||||
|
delete transformed.created_date;
|
||||||
}
|
}
|
||||||
if (!transformed.updated_date && tableColumns.some((col) => col.columnName === "updated_date")) {
|
if (!transformed.updated_date && tableColumns.some((col) => col.columnName === "updated_date")) {
|
||||||
transformed.updated_date = now;
|
transformed.updated_date = now;
|
||||||
|
|
|
||||||
|
|
@ -797,7 +797,15 @@ function isSourceOnlyNode(type: NodeType): boolean {
|
||||||
* 액션 노드 여부 확인
|
* 액션 노드 여부 확인
|
||||||
*/
|
*/
|
||||||
function isActionNode(type: NodeType): boolean {
|
function isActionNode(type: NodeType): boolean {
|
||||||
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
|
return (
|
||||||
|
type === "insertAction" ||
|
||||||
|
type === "updateAction" ||
|
||||||
|
type === "deleteAction" ||
|
||||||
|
type === "upsertAction" ||
|
||||||
|
type === "emailAction" || // 이메일 발송 액션
|
||||||
|
type === "scriptAction" || // 스크립트 실행 액션
|
||||||
|
type === "httpRequestAction" // HTTP 요청 액션
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2460,18 +2460,22 @@ export class ButtonActionExecutor {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "both":
|
case "both":
|
||||||
// 폼 + 테이블 선택
|
// 폼 + 테이블 선택 (데이터 병합)
|
||||||
sourceData = [];
|
// 🔥 각 selectedRowsData 항목에 formData를 병합하여 전달
|
||||||
if (context.formData && Object.keys(context.formData).length > 0) {
|
// 이렇게 해야 메일 발송 시 부모 데이터(상품명 등)와 폼 데이터(수신자 등)가 모두 변수로 사용 가능
|
||||||
sourceData.push(context.formData);
|
|
||||||
}
|
|
||||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
sourceData.push(...context.selectedRowsData);
|
sourceData = context.selectedRowsData.map((row: any) => ({
|
||||||
}
|
...row,
|
||||||
console.log("🔀 폼 + 테이블 선택 데이터 사용:", {
|
...(context.formData || {}),
|
||||||
|
}));
|
||||||
|
console.log("🔀 폼 + 테이블 선택 데이터 병합:", {
|
||||||
dataCount: sourceData.length,
|
dataCount: sourceData.length,
|
||||||
sourceData,
|
sourceData,
|
||||||
});
|
});
|
||||||
|
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
|
sourceData = [context.formData];
|
||||||
|
console.log("🔀 폼 데이터만 사용 (선택된 행 없음):", sourceData);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -2481,9 +2485,23 @@ export class ButtonActionExecutor {
|
||||||
dataSourceType = "flow-selection";
|
dataSourceType = "flow-selection";
|
||||||
console.log("🌊 [자동] 플로우 선택 데이터 사용");
|
console.log("🌊 [자동] 플로우 선택 데이터 사용");
|
||||||
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
// 🔥 selectedRowsData가 있으면 formData도 함께 병합
|
||||||
|
// 모달에서 부모 데이터(selectedRowsData)와 폼 입력(formData)을 모두 사용할 수 있도록
|
||||||
|
if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
|
sourceData = context.selectedRowsData.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
...context.formData,
|
||||||
|
}));
|
||||||
|
dataSourceType = "both";
|
||||||
|
console.log("📊 [자동] 테이블 선택 + 폼 데이터 병합 사용:", {
|
||||||
|
rowCount: context.selectedRowsData.length,
|
||||||
|
formDataKeys: Object.keys(context.formData),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
sourceData = context.selectedRowsData;
|
sourceData = context.selectedRowsData;
|
||||||
dataSourceType = "table-selection";
|
dataSourceType = "table-selection";
|
||||||
console.log("📊 [자동] 테이블 선택 데이터 사용");
|
console.log("📊 [자동] 테이블 선택 데이터 사용");
|
||||||
|
}
|
||||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
sourceData = [context.formData];
|
sourceData = [context.formData];
|
||||||
dataSourceType = "form";
|
dataSourceType = "form";
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ export interface ComponentConfigPanelProps {
|
||||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||||
tables?: any[]; // 전체 테이블 목록
|
tables?: any[]; // 전체 테이블 목록
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
|
||||||
|
allComponents?: any[]; // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||||
|
currentComponent?: any; // 🆕 현재 컴포넌트 정보
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||||
|
|
@ -143,6 +145,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
tableColumns,
|
tableColumns,
|
||||||
tables,
|
tables,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
allComponents,
|
||||||
|
currentComponent,
|
||||||
}) => {
|
}) => {
|
||||||
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
// 모든 useState를 최상단에 선언 (Hooks 규칙)
|
||||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||||
|
|
@ -432,6 +436,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
|
||||||
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||||
|
allComponents={allComponents} // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||||
|
currentComponent={currentComponent} // 🆕 현재 컴포넌트 정보
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/extension-placeholder": "^2.11.5",
|
||||||
|
"@tiptap/pm": "^2.11.5",
|
||||||
|
"@tiptap/react": "^2.11.5",
|
||||||
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
"@turf/buffer": "^7.2.0",
|
"@turf/buffer": "^7.2.0",
|
||||||
"@turf/helpers": "^7.2.0",
|
"@turf/helpers": "^7.2.0",
|
||||||
"@turf/intersect": "^7.2.0",
|
"@turf/intersect": "^7.2.0",
|
||||||
|
|
@ -1349,6 +1353,16 @@
|
||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/config": {
|
"node_modules/@prisma/config": {
|
||||||
"version": "6.18.0",
|
"version": "6.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
|
||||||
|
|
@ -2886,6 +2900,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@remirror/core-constants": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -3280,6 +3300,405 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-QrUX3muElDrNjKM3nqCSAtm3H3pT33c6ON8kwRiQboOAjT/9D57Cs7XEVY7r6rMaJPeKztrRUrNVF9w/w/6B0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-g4l4p892x/r7mhea8syp3fNYODxsDrimgouQ+q4DKXIgQmm5+uNhyuEPexP3I8TFNXqQ4DlMNFoM9yCqk97etQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-ki1R27VsSvY2tT9Q2DIlcATwLOoEjf5DsN+5sExarQ8S/ZxT/tvIjRxB8Dx7lb2a818W5f/NER26YchGtmHfpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-5FmnfXkJ76wN4EbJNzBhAlmQxho8yEMIJLchTGmXdsD/n/tsyVVtewnQYaIOj/Z7naaGySTGDmjVtLgTuQ+Sxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-i65wUGJevzBTIIUBHBc1ggVa27bgemvGl/tY1/89fEuS/0Xmre+OQjw8rCtSLevoHSiYYLgLRlvjtUSUhE4kgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-NtJzJY7Q/6XWjpOm5OXKrnEaofrcc1XOTYlo/SaTwl8k2bZo918Vl0IDBWhPVDsUN7kx767uHwbtuQZ+9I82hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-3MBQRGHHZ0by3OT0CWbLKS7J3PH9PpobrXjmIR7kr0nde7+bHqxXiVNuuIf501oKU9rnEUSedipSHkLYGkmfsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-nUk/8DbiXO69l6FDwkWso94BTf52IBoWALo+YGWT6o+FO6cI9LbUGghEX2CdmQYXCvSvwvISF2jXeLQWNZvPZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-W4hHa4Io6QCTwpyTlN6UAvqMIQ7t56kIUByZhyY9EWrg/+JpbfpxE1kXFLPB4ZGgwBknFOw+e4bJ1j3oAbTJFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-6xoC7igZlW1EmnQ5WVH9IL7P1nCQb3bBUaIDLvk7LbweEogcTUECI4Xg1vxMOVmj9tlDe1I4BsgfcKpB5KEsZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-history": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-K8PHC9gegSAt0wzSlsd4aUpoEyIJYOmVVeyniHr1P1mIblW1KYEDbRGbDlrLALTyUEfMcBhdIm8zrB9X2Nihvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-WxXWGEEsqDmGIF2o9av+3r9Qje4CKrqrpeQY6aRO5bxvWX9AabQCfasepayBok6uwtvNzh3Xpsn9zbbSk09dNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-rcm0GyniWW0UhcNI9+1eIK64GqWQLyIIrWGINslvqSUoBc+WkfocLvv4CMpRkzKlfsAxwVIBuH2eLxHKDtAREA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-dtsxvtzxfwOJP6dKGf0vb2MJAoDF2NxoiWzpq0XTvo7NGGYUHfuHjX07Zp0dYqb4seaDXjwsi5BIQUOp3+WMFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-R3QdrHcUdFAsdsn2UAIvhY0yWyHjqGyP/Rv8RRdN0OyFiTKtwTPqreKMHKJOflgX4sMJl/OpHTpNG1Kaf7Lo2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-UbXaibHHFE+lOTlw/vs3jPzBoj1sAfbXuTAhXChjgYIcTTY5Cr6yxwcymLcimbQ79gf04Xkua2FCN3YsJxIFmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-S9I//K8KPgfFTC5I5lorClzXk0g4lrAv9y5qHzHO5EOWt7AFl0YTg2oN8NKSIBK4bHRnPIrjJJKv+dDFnUp5jQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-a4GCT+GZ9tUwl82F4CEum9/+WsuW0/De9Be/NqrMmi7eNfAwbUTbLCTFU0gEvv25WMHCoUzaeNk/qGmzeVPJ1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text-style": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-markdown": "^1.13.1",
|
||||||
|
"prosemirror-menu": "^1.2.4",
|
||||||
|
"prosemirror-model": "^1.23.0",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.4.1",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-trailing-node": "^3.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.37.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/react": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-leJximSjYJuhLJQv9azOP9R7w6zuxVgKOHYT4w83Gte7GhWMpNL6xRWzld280vyq/YW/cSYjPb/8ESEOgKNBdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.27.1",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.27.1",
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"fast-deep-equal": "^3",
|
||||||
|
"use-sync-external-store": "^1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^2.27.1",
|
||||||
|
"@tiptap/extension-blockquote": "^2.27.1",
|
||||||
|
"@tiptap/extension-bold": "^2.27.1",
|
||||||
|
"@tiptap/extension-bullet-list": "^2.27.1",
|
||||||
|
"@tiptap/extension-code": "^2.27.1",
|
||||||
|
"@tiptap/extension-code-block": "^2.27.1",
|
||||||
|
"@tiptap/extension-document": "^2.27.1",
|
||||||
|
"@tiptap/extension-dropcursor": "^2.27.1",
|
||||||
|
"@tiptap/extension-gapcursor": "^2.27.1",
|
||||||
|
"@tiptap/extension-hard-break": "^2.27.1",
|
||||||
|
"@tiptap/extension-heading": "^2.27.1",
|
||||||
|
"@tiptap/extension-history": "^2.27.1",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.27.1",
|
||||||
|
"@tiptap/extension-italic": "^2.27.1",
|
||||||
|
"@tiptap/extension-list-item": "^2.27.1",
|
||||||
|
"@tiptap/extension-ordered-list": "^2.27.1",
|
||||||
|
"@tiptap/extension-paragraph": "^2.27.1",
|
||||||
|
"@tiptap/extension-strike": "^2.27.1",
|
||||||
|
"@tiptap/extension-text": "^2.27.1",
|
||||||
|
"@tiptap/extension-text-style": "^2.27.1",
|
||||||
|
"@tiptap/pm": "^2.27.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@turf/along": {
|
"node_modules/@turf/along": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
||||||
|
|
@ -5625,6 +6044,28 @@
|
||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.24",
|
"version": "20.19.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||||
|
|
@ -6495,7 +6936,6 @@
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
|
|
@ -7242,6 +7682,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
|
|
@ -8342,7 +8788,6 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
|
|
@ -10600,6 +11045,15 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -10724,6 +11178,35 @@
|
||||||
"integrity": "sha512-gz6nNQoVK7Lkh2pZulrT4qd4347S/toG9RXH2pyzhLgkL5mLkBoqgv4EvAGXcV0ikDW72n/OQb3Xe8bGagQZCg==",
|
"integrity": "sha512-gz6nNQoVK7Lkh2pZulrT4qd4347S/toG9RXH2pyzhLgkL5mLkBoqgv4EvAGXcV0ikDW72n/OQb3Xe8bGagQZCg==",
|
||||||
"license": "AGPL-3.0"
|
"license": "AGPL-3.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -10739,6 +11222,12 @@
|
||||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|
@ -11154,6 +11643,12 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
|
|
@ -11616,6 +12111,201 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-collab": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-menu": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.0",
|
||||||
|
"prosemirror-commands": "^1.0.0",
|
||||||
|
"prosemirror-history": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-wbqCR/RlRPRe41a4LFtmhKElzBEfBTdtAYWNIGHM6X2e24NN/MTNUKyXjjphfAfdQce37Kh/5yf765mLPYDe7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-trailing-node": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remirror/core-constants": "3.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.22.1",
|
||||||
|
"prosemirror-state": "^1.4.2",
|
||||||
|
"prosemirror-view": "^1.33.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.10.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
|
||||||
|
"integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
|
@ -11631,6 +12321,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
|
@ -12256,6 +12955,12 @@
|
||||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||||
"license": "Unlicense"
|
"license": "Unlicense"
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
@ -13125,6 +13830,15 @@
|
||||||
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
|
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/tippy.js": {
|
||||||
|
"version": "6.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
|
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tldts": {
|
"node_modules/tldts": {
|
||||||
"version": "7.0.17",
|
"version": "7.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz",
|
||||||
|
|
@ -13441,6 +14155,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
|
|
@ -13629,6 +14349,12 @@
|
||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
"test:dataflow": "jest lib/services/__tests__/buttonDataflowPerformance.test.ts"
|
"test:dataflow": "jest lib/services/__tests__/buttonDataflowPerformance.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-placeholder": "^2.11.5",
|
||||||
|
"@tiptap/pm": "^2.11.5",
|
||||||
|
"@tiptap/react": "^2.11.5",
|
||||||
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@ export type NodeType =
|
||||||
| "tableSource" // 테이블 소스
|
| "tableSource" // 테이블 소스
|
||||||
| "externalDBSource" // 외부 DB 소스
|
| "externalDBSource" // 외부 DB 소스
|
||||||
| "restAPISource" // REST API 소스
|
| "restAPISource" // REST API 소스
|
||||||
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
|
|
||||||
| "condition" // 조건 분기
|
| "condition" // 조건 분기
|
||||||
| "dataTransform" // 데이터 변환
|
| "dataTransform" // 데이터 변환
|
||||||
| "aggregate" // 집계 노드 (SUM, COUNT, AVG 등)
|
| "aggregate" // 집계 노드 (SUM, COUNT, AVG 등)
|
||||||
|
| "formulaTransform" // 수식 변환 노드
|
||||||
| "insertAction" // INSERT 액션
|
| "insertAction" // INSERT 액션
|
||||||
| "updateAction" // UPDATE 액션
|
| "updateAction" // UPDATE 액션
|
||||||
| "deleteAction" // DELETE 액션
|
| "deleteAction" // DELETE 액션
|
||||||
| "upsertAction" // UPSERT 액션
|
| "upsertAction" // UPSERT 액션
|
||||||
|
| "emailAction" // 메일 발송 액션
|
||||||
|
| "scriptAction" // 스크립트 실행 액션
|
||||||
|
| "httpRequestAction" // HTTP 요청 액션
|
||||||
| "comment" // 주석
|
| "comment" // 주석
|
||||||
| "log"; // 로그
|
| "log"; // 로그
|
||||||
|
|
||||||
|
|
@ -92,35 +95,6 @@ export interface RestAPISourceNodeData {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 참조 테이블 조회 노드 (내부 DB 전용)
|
|
||||||
export interface ReferenceLookupNodeData {
|
|
||||||
type: "referenceLookup";
|
|
||||||
referenceTable: string; // 참조할 테이블명
|
|
||||||
referenceTableLabel?: string; // 테이블 라벨
|
|
||||||
joinConditions: Array<{
|
|
||||||
// 조인 조건 (FK 매핑)
|
|
||||||
sourceField: string; // 소스 데이터의 필드
|
|
||||||
sourceFieldLabel?: string;
|
|
||||||
referenceField: string; // 참조 테이블의 필드
|
|
||||||
referenceFieldLabel?: string;
|
|
||||||
}>;
|
|
||||||
whereConditions?: Array<{
|
|
||||||
// 추가 WHERE 조건
|
|
||||||
field: string;
|
|
||||||
fieldLabel?: string;
|
|
||||||
operator: string;
|
|
||||||
value: any;
|
|
||||||
valueType?: "static" | "field"; // 고정값 또는 소스 필드 참조
|
|
||||||
}>;
|
|
||||||
outputFields: Array<{
|
|
||||||
// 가져올 필드들
|
|
||||||
fieldName: string; // 참조 테이블의 컬럼명
|
|
||||||
fieldLabel?: string;
|
|
||||||
alias: string; // 결과 데이터에서 사용할 이름
|
|
||||||
}>;
|
|
||||||
displayName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 조건 분기 노드
|
// 조건 분기 노드
|
||||||
export interface ConditionNodeData {
|
export interface ConditionNodeData {
|
||||||
conditions: Array<{
|
conditions: Array<{
|
||||||
|
|
@ -198,6 +172,108 @@ export interface DataTransformNodeData {
|
||||||
// 집계 함수 타입
|
// 집계 함수 타입
|
||||||
export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST";
|
export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 수식 변환 노드 (Formula Transform)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 수식 타입
|
||||||
|
export type FormulaType = "arithmetic" | "function" | "condition" | "static";
|
||||||
|
|
||||||
|
// 수식 변환 노드 데이터
|
||||||
|
export interface FormulaTransformNodeData {
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
// 타겟 테이블 조회 설정 (기존 값 참조용 - UPSERT 시나리오)
|
||||||
|
targetLookup?: {
|
||||||
|
tableName: string; // 조회할 테이블명
|
||||||
|
tableLabel?: string; // 테이블 라벨
|
||||||
|
lookupKeys: Array<{
|
||||||
|
// 조회 키 (source 필드와 매칭)
|
||||||
|
sourceField: string; // 소스 필드명
|
||||||
|
sourceFieldLabel?: string;
|
||||||
|
targetField: string; // 타겟 테이블의 필드명
|
||||||
|
targetFieldLabel?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 변환 규칙들
|
||||||
|
transformations: Array<{
|
||||||
|
id: string; // 고유 ID
|
||||||
|
outputField: string; // 출력 필드명
|
||||||
|
outputFieldLabel?: string; // 출력 필드 라벨
|
||||||
|
formulaType: FormulaType; // 수식 타입
|
||||||
|
|
||||||
|
// 산술 연산 (formulaType === "arithmetic")
|
||||||
|
arithmetic?: {
|
||||||
|
leftOperand: {
|
||||||
|
type: "source" | "target" | "static" | "result"; // 값 소스
|
||||||
|
field?: string; // source.* 또는 target.* 필드
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number; // 정적 값
|
||||||
|
resultField?: string; // 이전 변환 결과 필드 참조
|
||||||
|
};
|
||||||
|
operator: "+" | "-" | "*" | "/" | "%"; // 연산자
|
||||||
|
rightOperand: {
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 함수 (formulaType === "function")
|
||||||
|
function?: {
|
||||||
|
name: "NOW" | "COALESCE" | "CONCAT" | "UPPER" | "LOWER" | "TRIM" | "ROUND" | "ABS" | "SUBSTRING";
|
||||||
|
arguments: Array<{
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조건 (formulaType === "condition")
|
||||||
|
condition?: {
|
||||||
|
when: {
|
||||||
|
leftOperand: {
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
};
|
||||||
|
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "IS_NULL" | "IS_NOT_NULL";
|
||||||
|
rightOperand?: {
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
then: {
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
};
|
||||||
|
else: {
|
||||||
|
type: "source" | "target" | "static" | "result";
|
||||||
|
field?: string;
|
||||||
|
fieldLabel?: string;
|
||||||
|
value?: string | number;
|
||||||
|
resultField?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정적 값 (formulaType === "static")
|
||||||
|
staticValue?: string | number | boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// 집계 노드 데이터
|
// 집계 노드 데이터
|
||||||
export interface AggregateNodeData {
|
export interface AggregateNodeData {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
@ -423,6 +499,175 @@ export interface LogNodeData {
|
||||||
includeData?: boolean;
|
includeData?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 외부 연동 액션 노드 (메일, 스크립트, HTTP 요청)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// 메일 발송 액션 노드
|
||||||
|
export interface EmailActionNodeData {
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
// 메일 계정 선택 (메일관리에서 등록한 계정)
|
||||||
|
accountId?: string; // 메일 계정 ID (우선 사용)
|
||||||
|
|
||||||
|
// 🆕 수신자 컴포넌트 사용 여부
|
||||||
|
useRecipientComponent?: boolean; // true면 {{mailTo}}, {{mailCc}} 자동 사용
|
||||||
|
recipientToField?: string; // 수신자 필드명 (기본: mailTo)
|
||||||
|
recipientCcField?: string; // 참조 필드명 (기본: mailCc)
|
||||||
|
|
||||||
|
// SMTP 서버 설정 (직접 설정 시 사용, accountId가 있으면 무시됨)
|
||||||
|
smtpConfig?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean; // true = SSL/TLS
|
||||||
|
auth?: {
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메일 내용
|
||||||
|
from?: string; // 발신자 이메일 (계정 선택 시 자동 설정)
|
||||||
|
to: string; // 수신자 이메일 (쉼표로 구분하여 여러 명) - useRecipientComponent가 true면 무시됨
|
||||||
|
cc?: string; // 참조 - useRecipientComponent가 true면 무시됨
|
||||||
|
bcc?: string; // 숨은 참조
|
||||||
|
subject: string; // 제목 (템플릿 변수 지원)
|
||||||
|
body: string; // 본문 (템플릿 변수 지원)
|
||||||
|
bodyType: "text" | "html"; // 본문 형식
|
||||||
|
|
||||||
|
// 첨부파일 (선택)
|
||||||
|
attachments?: Array<{
|
||||||
|
filename: string;
|
||||||
|
path?: string; // 파일 경로
|
||||||
|
content?: string; // Base64 인코딩된 내용
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 고급 설정
|
||||||
|
replyTo?: string;
|
||||||
|
priority?: "high" | "normal" | "low";
|
||||||
|
|
||||||
|
// 실행 옵션
|
||||||
|
options?: {
|
||||||
|
retryCount?: number;
|
||||||
|
retryDelay?: number; // ms
|
||||||
|
timeout?: number; // ms
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 실행 액션 노드
|
||||||
|
export interface ScriptActionNodeData {
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
// 스크립트 타입
|
||||||
|
scriptType: "python" | "shell" | "powershell" | "node" | "executable";
|
||||||
|
|
||||||
|
// 실행 방식
|
||||||
|
executionMode: "inline" | "file";
|
||||||
|
|
||||||
|
// 인라인 스크립트 (executionMode === "inline")
|
||||||
|
inlineScript?: string;
|
||||||
|
|
||||||
|
// 파일 경로 (executionMode === "file")
|
||||||
|
scriptPath?: string;
|
||||||
|
|
||||||
|
// 실행 파일 경로 (scriptType === "executable")
|
||||||
|
executablePath?: string;
|
||||||
|
|
||||||
|
// 명령줄 인자
|
||||||
|
arguments?: string[];
|
||||||
|
|
||||||
|
// 환경 변수
|
||||||
|
environmentVariables?: Record<string, string>;
|
||||||
|
|
||||||
|
// 입력 데이터 전달 방식
|
||||||
|
inputMethod: "stdin" | "args" | "env" | "file";
|
||||||
|
inputFormat?: "json" | "csv" | "text"; // stdin/file 사용 시
|
||||||
|
|
||||||
|
// 작업 디렉토리
|
||||||
|
workingDirectory?: string;
|
||||||
|
|
||||||
|
// 실행 옵션
|
||||||
|
options?: {
|
||||||
|
timeout?: number; // ms (기본: 60000)
|
||||||
|
maxBuffer?: number; // bytes (기본: 1MB)
|
||||||
|
shell?: string; // 사용할 쉘 (예: /bin/bash)
|
||||||
|
encoding?: string; // 출력 인코딩 (기본: utf8)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 출력 처리
|
||||||
|
outputHandling?: {
|
||||||
|
captureStdout: boolean;
|
||||||
|
captureStderr: boolean;
|
||||||
|
parseOutput?: "json" | "lines" | "text";
|
||||||
|
successExitCodes?: number[]; // 성공으로 간주할 종료 코드 (기본: [0])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 요청 액션 노드
|
||||||
|
export interface HttpRequestActionNodeData {
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
// 기본 설정
|
||||||
|
url: string; // URL (템플릿 변수 지원)
|
||||||
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
// 쿼리 파라미터
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
|
||||||
|
// 요청 본문
|
||||||
|
bodyType?: "none" | "json" | "form" | "text" | "binary";
|
||||||
|
body?: string; // JSON 문자열 또는 텍스트 (템플릿 변수 지원)
|
||||||
|
formData?: Array<{
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
type: "text" | "file";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// 인증
|
||||||
|
authentication?: {
|
||||||
|
type: "none" | "basic" | "bearer" | "apikey" | "oauth2";
|
||||||
|
// Basic Auth
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
// Bearer Token
|
||||||
|
token?: string;
|
||||||
|
// API Key
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyName?: string;
|
||||||
|
apiKeyLocation?: "header" | "query";
|
||||||
|
// OAuth2 (향후 확장)
|
||||||
|
oauth2Config?: {
|
||||||
|
grantType: "client_credentials" | "password" | "authorization_code";
|
||||||
|
tokenUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scope?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 고급 설정
|
||||||
|
options?: {
|
||||||
|
timeout?: number; // ms (기본: 30000)
|
||||||
|
followRedirects?: boolean; // 리다이렉트 따라가기 (기본: true)
|
||||||
|
maxRedirects?: number; // 최대 리다이렉트 횟수 (기본: 5)
|
||||||
|
validateStatus?: string; // 성공 상태 코드 범위 (예: "2xx", "200-299")
|
||||||
|
retryCount?: number;
|
||||||
|
retryDelay?: number; // ms
|
||||||
|
retryOn?: ("timeout" | "5xx" | "network")[]; // 재시도 조건
|
||||||
|
};
|
||||||
|
|
||||||
|
// 응답 처리
|
||||||
|
responseHandling?: {
|
||||||
|
extractPath?: string; // JSON 경로 (예: "data.items")
|
||||||
|
saveToVariable?: string; // 결과를 저장할 변수명
|
||||||
|
validateSchema?: boolean; // JSON 스키마 검증
|
||||||
|
expectedSchema?: object; // 예상 스키마
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 통합 노드 데이터 타입
|
// 통합 노드 데이터 타입
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -431,15 +676,18 @@ export type NodeData =
|
||||||
| TableSourceNodeData
|
| TableSourceNodeData
|
||||||
| ExternalDBSourceNodeData
|
| ExternalDBSourceNodeData
|
||||||
| RestAPISourceNodeData
|
| RestAPISourceNodeData
|
||||||
| ReferenceLookupNodeData
|
|
||||||
| ConditionNodeData
|
| ConditionNodeData
|
||||||
| FieldMappingNodeData
|
| FieldMappingNodeData
|
||||||
| DataTransformNodeData
|
| DataTransformNodeData
|
||||||
| AggregateNodeData
|
| AggregateNodeData
|
||||||
|
| FormulaTransformNodeData
|
||||||
| InsertActionNodeData
|
| InsertActionNodeData
|
||||||
| UpdateActionNodeData
|
| UpdateActionNodeData
|
||||||
| DeleteActionNodeData
|
| DeleteActionNodeData
|
||||||
| UpsertActionNodeData
|
| UpsertActionNodeData
|
||||||
|
| EmailActionNodeData
|
||||||
|
| ScriptActionNodeData
|
||||||
|
| HttpRequestActionNodeData
|
||||||
| CommentNodeData
|
| CommentNodeData
|
||||||
| LogNodeData;
|
| LogNodeData;
|
||||||
|
|
||||||
|
|
@ -557,7 +805,7 @@ export interface NodePaletteItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: "source" | "transform" | "action" | "utility";
|
category: "source" | "transform" | "action" | "external" | "utility";
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,11 +267,24 @@ export interface NumberTypeConfig {
|
||||||
* 선택박스 타입 설정
|
* 선택박스 타입 설정
|
||||||
*/
|
*/
|
||||||
export interface SelectTypeConfig {
|
export interface SelectTypeConfig {
|
||||||
options: Array<{ label: string; value: string }>;
|
options: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
|
||||||
|
/** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
|
cascadingRelationCode?: string;
|
||||||
|
|
||||||
|
/** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
|
/** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */
|
||||||
|
cascading?: CascadingDropdownConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -352,6 +365,58 @@ export interface EntityTypeConfig {
|
||||||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 연쇄 드롭다운(Cascading Dropdown) 설정
|
||||||
|
*
|
||||||
|
* 부모 필드의 값에 따라 자식 드롭다운의 옵션이 동적으로 변경됩니다.
|
||||||
|
* 예: 창고 선택 → 해당 창고의 위치만 표시
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 창고 → 위치 연쇄 드롭다운
|
||||||
|
* {
|
||||||
|
* enabled: true,
|
||||||
|
* parentField: "warehouse_code",
|
||||||
|
* sourceTable: "warehouse_location",
|
||||||
|
* parentKeyColumn: "warehouse_id",
|
||||||
|
* valueColumn: "location_code",
|
||||||
|
* labelColumn: "location_name",
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface CascadingDropdownConfig {
|
||||||
|
/** 연쇄 드롭다운 활성화 여부 */
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */
|
||||||
|
parentField: string;
|
||||||
|
|
||||||
|
/** 옵션을 조회할 테이블명 */
|
||||||
|
sourceTable: string;
|
||||||
|
|
||||||
|
/** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */
|
||||||
|
parentKeyColumn: string;
|
||||||
|
|
||||||
|
/** 드롭다운 value로 사용할 컬럼명 */
|
||||||
|
valueColumn: string;
|
||||||
|
|
||||||
|
/** 드롭다운 label로 표시할 컬럼명 */
|
||||||
|
labelColumn: string;
|
||||||
|
|
||||||
|
/** 추가 필터 조건 (선택사항) */
|
||||||
|
additionalFilters?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 부모 값이 없을 때 표시할 메시지 */
|
||||||
|
emptyParentMessage?: string;
|
||||||
|
|
||||||
|
/** 옵션이 없을 때 표시할 메시지 */
|
||||||
|
noOptionsMessage?: string;
|
||||||
|
|
||||||
|
/** 로딩 중 표시할 메시지 */
|
||||||
|
loadingMessage?: string;
|
||||||
|
|
||||||
|
/** 부모 값 변경 시 자동으로 값 초기화 */
|
||||||
|
clearOnParentChange?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 타입 설정
|
* 버튼 타입 설정
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1683,3 +1683,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -530,3 +530,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -517,3 +517,4 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue