; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2025-12-11 13:48:57 +09:00
commit 99fd8336a5
124 changed files with 25979 additions and 3887 deletions

View File

@ -76,6 +76,11 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -247,6 +252,11 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -702,6 +702,15 @@ export class DashboardController {
requestConfig.data = body;
}
// 디버깅 로그: 실제 요청 정보 출력
logger.info(`[fetchExternalApi] 요청 정보:`, {
url: requestConfig.url,
method: requestConfig.method,
headers: requestConfig.headers,
body: requestConfig.data,
externalConnectionId,
});
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];

View File

@ -1256,8 +1256,17 @@ export async function updateMenu(
}
}
const requestCompanyCode =
menuData.companyCode || menuData.company_code || currentMenu.company_code;
let requestCompanyCode =
menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = currentMenu.company_code;
}
// company_code 변경 시도하는 경우 권한 체크
if (requestCompanyCode !== currentMenu.company_code) {

View File

@ -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,
});
}
};

View File

@ -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;
}
}

View File

@ -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,
});
}
};

View File

@ -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,
});
}
};

View File

@ -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,
});
}
};

View File

@ -2141,3 +2141,4 @@ export async function multiTableSave(
client.release();
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
*
* POST /api/dataflow/node-flows/:flowId/execute
*/
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const contextData = req.body;
router.post(
"/:flowId/execute",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowId } = req.params;
const contextData = req.body;
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
contextDataKeys: Object.keys(contextData),
userId: req.user?.userId,
companyCode: req.user?.companyCode,
});
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
contextDataKeys: Object.keys(contextData),
userId: req.user?.userId,
companyCode: req.user?.companyCode,
});
// 사용자 정보를 contextData에 추가
const enrichedContextData = {
...contextData,
userId: req.user?.userId,
userName: req.user?.userName,
companyCode: req.user?.companyCode,
};
// 🔍 디버깅: req.user 전체 확인
logger.info(`🔍 req.user 전체 정보:`, {
user: req.user,
hasUser: !!req.user,
});
// 플로우 실행
const result = await NodeFlowExecutionService.executeFlow(
parseInt(flowId, 10),
enrichedContextData
);
// 사용자 정보를 contextData에 추가
const enrichedContextData = {
...contextData,
userId: req.user?.userId,
userName: req.user?.userName,
companyCode: req.user?.companyCode,
};
return res.json({
success: result.success,
message: result.message,
data: result,
});
} catch (error) {
logger.error("플로우 실행 실패:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error
? error.message
: "플로우 실행 중 오류가 발생했습니다.",
});
// 🔍 디버깅: enrichedContextData 확인
logger.info(`🔍 enrichedContextData:`, {
userId: enrichedContextData.userId,
companyCode: enrichedContextData.companyCode,
});
// 플로우 실행
const result = await NodeFlowExecutionService.executeFlow(
parseInt(flowId, 10),
enrichedContextData
);
return res.json({
success: result.success,
message: result.message,
data: result,
});
} catch (error) {
logger.error("플로우 실행 실패:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error
? error.message
: "플로우 실행 중 오류가 발생했습니다.",
});
}
}
});
);
export default router;

View File

@ -19,15 +19,21 @@ export class AdminService {
// menuType에 따른 WHERE 조건 생성
const menuTypeCondition =
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
menuType !== undefined
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
: "1 = 1";
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
const includeInactive = paramMap.includeInactive === true;
const isManagementScreen = includeInactive || menuType === undefined;
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
const statusCondition = isManagementScreen
? "1 = 1"
: "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen
? "1 = 1"
: "MENU_SUB.STATUS = 'active'";
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
let authFilter = "";
@ -35,7 +41,11 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
if (
menuType !== undefined &&
userType !== "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
const userRoleGroups = await query<any>(
`
@ -56,45 +66,45 @@ export class AdminService {
);
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
authFilter = `
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU.OBJID
AND rma.auth_objid = ANY($${paramIndex + 1})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(userCompanyCode);
const companyParamIndex = paramIndex;
paramIndex++;
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
// 하위 메뉴 권한 체크
unionFilter = `
AND (
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
OR (
MENU_SUB.COMPANY_CODE = '*'
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
)
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(roleObjids);
paramIndex++;
logger.info(
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
);
} else {
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
logger.info(
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
logger.warn(
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
);
return [];
}
} else {
// 일반 사용자: 권한 그룹 필수
@ -131,7 +141,11 @@ export class AdminService {
return [];
}
}
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
@ -167,7 +181,7 @@ export class AdminService {
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
if (unionFilter === "") {
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;

View File

@ -1,12 +1,12 @@
/**
*
*
*
* :
* 1. -
* 2. -
* 3. - company_code
* 4. SQL - /
*
*
* :
* - , ,
* - (pg_*, information_schema )
@ -70,11 +70,11 @@ class DataService {
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
@ -83,12 +83,12 @@ class DataService {
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
@ -103,7 +103,7 @@ class DataService {
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
@ -117,38 +117,41 @@ class DataService {
}
selectedRow = rows[0];
break;
case "base_price":
// base_price = true인 행 찾기
selectedRow = rows.find(row => row.base_price === true) || rows[0];
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
break;
case "current_date":
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
const today = new Date();
today.setHours(0, 0, 0, 0); // 시간 제거
selectedRow = 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;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
selectedRow =
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;
if (startDate) startDate.setHours(0, 0, 0, 0);
if (endDate) endDate.setHours(0, 0, 0, 0);
const afterStart = !startDate || today >= startDate;
const beforeEnd = !endDate || today <= endDate;
return afterStart && beforeEnd;
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
break;
default:
selectedRow = rows[0];
}
result.push(selectedRow);
}
return result;
}
@ -230,12 +233,17 @@ class DataService {
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
const hasCompanyCode = await this.checkColumnExists(
tableName,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(userCompany);
paramIndex++;
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
console.log(
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
);
}
}
@ -508,7 +516,8 @@ class DataService {
const entityJoinService = new EntityJoinService();
// Entity Join 구성 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
const joinConfigs =
await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length > 0) {
console.log(`✅ Entity Join 감지: ${joinConfigs.length}`);
@ -518,7 +527,7 @@ class DataService {
tableName,
joinConfigs,
["*"],
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
);
const result = await pool.query(joinQuery, [id]);
@ -533,14 +542,14 @@ class DataService {
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
return rows.map((row) => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
@ -551,17 +560,20 @@ class DataService {
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
console.log(
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
normalizedRows[0]
);
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
if (groupByColumns.length > 0) {
const baseRecord = result.rows[0];
// 그룹핑 컬럼들의 값 추출
const groupConditions: string[] = [];
const groupValues: any[] = [];
let paramIndex = 1;
for (const col of groupByColumns) {
const value = normalizedRows[0][col];
if (value !== undefined && value !== null) {
@ -570,12 +582,15 @@ class DataService {
paramIndex++;
}
}
if (groupConditions.length > 0) {
const groupWhereClause = groupConditions.join(" AND ");
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
console.log(
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
groupValues
);
// 그룹핑 기준으로 모든 레코드 조회
const { query: groupQuery } = entityJoinService.buildJoinQuery(
tableName,
@ -583,12 +598,14 @@ class DataService {
["*"],
groupWhereClause
);
const groupResult = await pool.query(groupQuery, groupValues);
const normalizedGroupRows = normalizeDates(groupResult.rows);
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`);
console.log(
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}`
);
return {
success: true,
data: normalizedGroupRows, // 🔧 배열로 반환!
@ -642,7 +659,8 @@ class DataService {
dataFilter?: any, // 🆕 데이터 필터
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
deduplication?: { // 🆕 중복 제거 설정
deduplication?: {
// 🆕 중복 제거 설정
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
@ -666,36 +684,41 @@ class DataService {
if (enableEntityJoin) {
try {
const { entityJoinService } = await import("./entityJoinService");
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
const joinConfigs =
await entityJoinService.detectEntityJoins(rightTable);
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
if (displayColumns && Array.isArray(displayColumns)) {
// 테이블별로 요청된 컬럼들을 그룹핑
const tableColumns: Record<string, Set<string>> = {};
for (const col of displayColumns) {
if (col.name && col.name.includes('.')) {
const [refTable, refColumn] = col.name.split('.');
if (col.name && col.name.includes(".")) {
const [refTable, refColumn] = col.name.split(".");
if (!tableColumns[refTable]) {
tableColumns[refTable] = new Set();
}
tableColumns[refTable].add(refColumn);
}
}
// 각 테이블별로 처리
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) {
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
for (const refColumn of refColumns) {
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
const existingJoin = existingJoins.find(
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
(jc) =>
jc.displayColumns.length === 1 &&
jc.displayColumns[0] === refColumn
);
if (!existingJoin) {
// 없으면 새 조인 설정 복제하여 추가
const baseJoin = existingJoins[0];
@ -708,7 +731,9 @@ class DataService {
referenceColumn: baseJoin.referenceColumn, // item_number 등
};
joinConfigs.push(newJoin);
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
console.log(
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
);
}
}
} else {
@ -718,7 +743,9 @@ class DataService {
}
if (joinConfigs.length > 0) {
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
console.log(
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
);
// WHERE 조건 생성
const whereConditions: string[] = [];
@ -735,7 +762,10 @@ class DataService {
// 회사별 필터링
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`main.company_code = $${paramIndex}`);
values.push(userCompany);
@ -744,48 +774,64 @@ class DataService {
}
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const { buildDataFilterWhereClause } = await import(
"../utils/dataFilterUtil"
);
const filterResult = buildDataFilterWhereClause(
dataFilter,
"main",
paramIndex
);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
console.log(`📊 필터 파라미터:`, filterResult.params);
}
}
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
const whereClause =
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
// Entity 조인 쿼리 빌드
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
const selectColumns = ["*"];
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
const { query: finalQuery, aliasMap } =
entityJoinService.buildJoinQuery(
rightTable,
joinConfigs,
selectColumns,
whereClause,
"",
undefined,
undefined
);
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
console.log(`🔍 파라미터:`, values);
const result = await pool.query(finalQuery, values);
// 🔧 날짜 타입 타임존 문제 해결
const normalizeDates = (rows: any[]) => {
return rows.map(row => {
return rows.map((row) => {
const normalized: any = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
normalized[key] = `${year}-${month}-${day}`;
} else {
normalized[key] = value;
@ -794,18 +840,24 @@ class DataService {
return normalized;
});
};
const normalizedRows = normalizeDates(result.rows);
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
console.log(
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
);
// 🆕 중복 제거 처리
let finalData = normalizedRows;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(normalizedRows, deduplication);
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`);
console.log(
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}`
);
}
return {
success: true,
data: finalData,
@ -838,23 +890,40 @@ class DataService {
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
if (userCompany && userCompany !== "*") {
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
const hasCompanyCode = await this.checkColumnExists(
rightTable,
"company_code"
);
if (hasCompanyCode) {
whereConditions.push(`r.company_code = $${paramIndex}`);
values.push(userCompany);
paramIndex++;
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
console.log(
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
);
}
}
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const filterResult = buildDataFilterWhereClause(
dataFilter,
"r",
paramIndex
);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
console.log(
`🔍 데이터 필터 적용 (${rightTable}):`,
filterResult.whereClause
);
}
}
@ -871,9 +940,13 @@ class DataService {
// 🆕 중복 제거 처리
let finalData = result;
if (deduplication?.enabled && deduplication.groupByColumn) {
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
console.log(
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
);
finalData = this.deduplicateData(result, deduplication);
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`);
console.log(
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}`
);
}
return {
@ -909,8 +982,10 @@ class DataService {
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
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 filteredData = Object.fromEntries(
Object.entries(data).filter(([key]) => {
@ -921,9 +996,11 @@ class DataService {
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
console.log(
`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
);
}
const columns = Object.keys(filteredData);
@ -975,8 +1052,10 @@ class DataService {
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
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[] = [];
cleanData = Object.fromEntries(
Object.entries(cleanData).filter(([key]) => {
@ -987,9 +1066,11 @@ class DataService {
return false;
})
);
if (invalidColumns.length > 0) {
console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`);
console.log(
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
);
}
// Primary Key 컬럼 찾기
@ -1031,8 +1112,14 @@ class DataService {
}
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
if (
relationInfo &&
relationInfo.rightTable &&
relationInfo.leftColumn &&
relationInfo.rightColumn
) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
relationInfo;
const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
@ -1050,8 +1137,13 @@ class DataService {
SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2
`;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
const updateResult = await query(updateRelatedQuery, [
newLeftValue,
oldLeftValue,
]);
console.log(
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
);
} catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
@ -1102,9 +1194,11 @@ class DataService {
if (pkResult.length > 1) {
// 복합키인 경우: id가 객체여야 함
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
if (typeof id === 'object' && !Array.isArray(id)) {
console.log(
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
);
if (typeof id === "object" && !Array.isArray(id)) {
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
pkResult.forEach((pk, index) => {
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
@ -1119,15 +1213,17 @@ class DataService {
// 단일키인 경우
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
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);
const result = await query<any>(queryText, params);
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
console.log(
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
);
return {
success: true,
@ -1166,7 +1262,11 @@ class DataService {
}
if (whereConditions.length === 0) {
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
return {
success: false,
message: "삭제 조건이 없습니다.",
error: "NO_CONDITIONS",
};
}
const whereClause = whereConditions.join(" AND ");
@ -1201,7 +1301,9 @@ class DataService {
records: Array<Record<string, any>>,
userCompany?: string,
userId?: string
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
): Promise<
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
> {
try {
// 테이블 접근 권한 검증
const validation = await this.validateTableAccess(tableName);
@ -1239,11 +1341,14 @@ class DataService {
const whereClause = whereConditions.join(" AND ");
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);
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
@ -1254,50 +1359,53 @@ class DataService {
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split('T')[0]; // YYYY-MM-DD 만 추출
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split("T")[0]; // YYYY-MM-DD 만 추출
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord);
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
@ -1310,7 +1418,8 @@ class DataService {
let updateParamIndex = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
if (key !== pkColumn) {
// Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateValues.push(value);
updateParamIndex++;
@ -1326,36 +1435,42 @@ class DataService {
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
const recordWithMeta: Record<string, any> = {
...fullRecord,
...recordWithoutCreatedDate,
id: uuidv4(), // 새 ID 생성
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
if (
!recordWithMeta.company_code &&
userCompany &&
userCompany !== "*"
) {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(key =>
recordWithMeta[key] !== "NOW()"
const insertFields = Object.keys(recordWithMeta).filter(
(key) => recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
@ -1367,15 +1482,20 @@ class DataService {
}
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(", ")})
`;
console.log(` INSERT 쿼리:`, { query: insertQuery, values: insertValues });
console.log(` INSERT 쿼리:`, {
query: insertQuery,
values: insertValues,
});
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
}
}
@ -1383,19 +1503,22 @@ class DataService {
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === 'string') {
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
return String(existingValue) === String(newValue);
});
});
@ -1405,7 +1528,7 @@ class DataService {
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
}
}

View File

@ -103,12 +103,16 @@ export class DynamicFormService {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
// DATE 타입이면 문자열 그대로 유지
if (lowerDataType === "date") {
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
console.log(
`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`
);
return value; // 문자열 그대로 반환
}
// TIMESTAMP 타입이면 Date 객체로 변환
else {
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
console.log(
`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`
);
return new Date(value + "T00:00:00");
}
}
@ -250,7 +254,8 @@ export class DynamicFormService {
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
dataToInsert.regdate = new Date();
}
if (tableColumns.includes("created_date") && !dataToInsert.created_date) {
// created_date는 항상 현재 시간으로 설정 (기존 값 무시)
if (tableColumns.includes("created_date")) {
dataToInsert.created_date = new Date();
}
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
@ -313,7 +318,9 @@ export class DynamicFormService {
}
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
console.log(
`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`
);
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
}
}
@ -346,35 +353,37 @@ export class DynamicFormService {
) {
try {
parsedArray = JSON.parse(value);
console.log(
console.log(
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
);
);
} catch (parseError) {
console.log(`⚠️ JSON 파싱 실패: ${key}`);
}
}
// 파싱된 배열이 있으면 처리
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
let targetTable: string | undefined;
let actualData = parsedArray;
if (
parsedArray &&
Array.isArray(parsedArray) &&
parsedArray.length > 0
) {
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
let targetTable: string | undefined;
let actualData = parsedArray;
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
if (parsedArray[0] && parsedArray[0]._targetTable) {
targetTable = parsedArray[0]._targetTable;
actualData = parsedArray.map(
({ _targetTable, ...item }) => item
);
}
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
if (parsedArray[0] && parsedArray[0]._targetTable) {
targetTable = parsedArray[0]._targetTable;
actualData = parsedArray.map(({ _targetTable, ...item }) => item);
}
repeaterData.push({
data: actualData,
targetTable,
componentId: key,
});
delete dataToInsert[key]; // 원본 배열 데이터는 제거
repeaterData.push({
data: actualData,
targetTable,
componentId: key,
});
delete dataToInsert[key]; // 원본 배열 데이터는 제거
console.log(`✅ Repeater 데이터 추가: ${key}`, {
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
@ -387,8 +396,8 @@ export class DynamicFormService {
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
const separateRepeaterData: typeof repeaterData = [];
const mergedRepeaterData: typeof repeaterData = [];
repeaterData.forEach(repeater => {
repeaterData.forEach((repeater) => {
if (repeater.targetTable && repeater.targetTable !== tableName) {
// 다른 테이블: 나중에 별도 저장
separateRepeaterData.push(repeater);
@ -397,10 +406,10 @@ export class DynamicFormService {
mergedRepeaterData.push(repeater);
}
});
console.log(`🔄 Repeater 데이터 분류:`, {
separate: separateRepeaterData.length, // 별도 테이블
merged: mergedRepeaterData.length, // 메인 테이블과 병합
merged: mergedRepeaterData.length, // 메인 테이블과 병합
});
// 존재하지 않는 컬럼 제거
@ -494,23 +503,30 @@ export class DynamicFormService {
const clientIp = ipAddress || "unknown";
let result: any[];
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
if (mergedRepeaterData.length > 0) {
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
console.log(
`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`
);
result = [];
for (const repeater of mergedRepeaterData) {
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 생성되도록 함
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
const isExistingRecord = rawMergedData._existingRecord === true;
if (!isExistingRecord) {
// 새 레코드: id 제거하여 새 UUID 자동 생성
const oldId = rawMergedData.id;
@ -519,37 +535,43 @@ export class DynamicFormService {
} else {
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
}
// 메타 플래그 제거
delete rawMergedData._isNewItem;
delete rawMergedData._existingRecord;
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
const validColumnNames = columnInfo.map((col) => col.column_name);
const mergedData: Record<string, any> = {};
Object.keys(rawMergedData).forEach((columnName) => {
// 실제 테이블 컬럼인지 확인
if (validColumnNames.includes(columnName)) {
const column = columnInfo.find((col) => col.column_name === columnName);
if (column) {
// 타입 변환
mergedData[columnName] = this.convertValueForPostgreSQL(
rawMergedData[columnName],
column.data_type
const column = columnInfo.find(
(col) => col.column_name === columnName
);
if (column) {
// 타입 변환
mergedData[columnName] = this.convertValueForPostgreSQL(
rawMergedData[columnName],
column.data_type
);
} else {
mergedData[columnName] = rawMergedData[columnName];
}
} else {
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
console.log(
`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`
);
}
});
const mergedColumns = Object.keys(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;
if (primaryKeys.length > 0) {
const conflictColumns = primaryKeys.join(", ");
@ -557,7 +579,7 @@ export class DynamicFormService {
.filter((col) => !primaryKeys.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
mergedUpsertQuery = updateSet
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
VALUES (${mergedPlaceholders})
@ -574,20 +596,20 @@ export class DynamicFormService {
VALUES (${mergedPlaceholders})
RETURNING *`;
}
console.log(`📝 병합 INSERT:`, { mergedData });
const itemResult = await transaction(async (client) => {
await client.query(`SET LOCAL app.user_id = '${userId}'`);
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
const res = await client.query(mergedUpsertQuery, mergedValues);
return res.rows[0];
});
result.push(itemResult);
}
}
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
} else {
// 일반 모드: 헤더만 저장
@ -597,7 +619,7 @@ export class DynamicFormService {
const res = await client.query(upsertQuery, values);
return res.rows;
});
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
}
@ -732,12 +754,19 @@ export class DynamicFormService {
// 🎯 제어관리 실행 (새로 추가)
try {
// savedData 또는 insertedRecord에서 company_code 추출
const recordCompanyCode =
(insertedRecord as Record<string, any>)?.company_code ||
dataToInsert.company_code ||
"*";
await this.executeDataflowControlIfConfigured(
screenId,
tableName,
insertedRecord as Record<string, any>,
"insert",
created_by || "system"
created_by || "system",
recordCompanyCode
);
} catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError);
@ -843,10 +872,10 @@ export class DynamicFormService {
FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
`;
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
columnTypesQuery,
[tableName]
);
const columnTypesResult = await query<{
column_name: string;
data_type: string;
}>(columnTypesQuery, [tableName]);
const columnTypes: Record<string, string> = {};
columnTypesResult.forEach((row) => {
columnTypes[row.column_name] = row.data_type;
@ -859,11 +888,20 @@ export class DynamicFormService {
.map((key, index) => {
const dataType = columnTypes[key];
// 숫자 타입인 경우 명시적 캐스팅
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
if (
dataType === "integer" ||
dataType === "bigint" ||
dataType === "smallint"
) {
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`;
} else if (dataType === 'boolean') {
} else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`;
} else if (dataType === 'jsonb' || dataType === 'json') {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
@ -890,13 +928,17 @@ export class DynamicFormService {
// 🔑 Primary Key 타입에 맞게 캐스팅
const pkDataType = columnTypes[primaryKeyColumn];
let pkCast = '';
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
pkCast = '::integer';
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
pkCast = '::numeric';
} else if (pkDataType === 'uuid') {
pkCast = '::uuid';
let pkCast = "";
if (
pkDataType === "integer" ||
pkDataType === "bigint" ||
pkDataType === "smallint"
) {
pkCast = "::integer";
} else if (pkDataType === "numeric" || pkDataType === "decimal") {
pkCast = "::numeric";
} else if (pkDataType === "uuid") {
pkCast = "::uuid";
}
// text, varchar 등은 캐스팅 불필요
@ -1085,12 +1127,19 @@ export class DynamicFormService {
// 🎯 제어관리 실행 (UPDATE 트리거)
try {
// updatedRecord에서 company_code 추출
const recordCompanyCode =
(updatedRecord as Record<string, any>)?.company_code ||
company_code ||
"*";
await this.executeDataflowControlIfConfigured(
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName,
updatedRecord as Record<string, any>,
"update",
updated_by || "system"
updated_by || "system",
recordCompanyCode
);
} catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError);
@ -1229,12 +1278,17 @@ export class DynamicFormService {
try {
if (result && Array.isArray(result) && result.length > 0) {
const deletedRecord = result[0] as Record<string, any>;
// deletedRecord에서 company_code 추출
const recordCompanyCode =
deletedRecord?.company_code || companyCode || "*";
await this.executeDataflowControlIfConfigured(
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
tableName,
deletedRecord,
"delete",
userId || "system"
userId || "system",
recordCompanyCode
);
}
} catch (controlError) {
@ -1540,7 +1594,8 @@ export class DynamicFormService {
tableName: string,
savedData: Record<string, any>,
triggerType: "insert" | "update" | "delete",
userId: string = "system"
userId: string = "system",
companyCode: string = "*"
): Promise<void> {
try {
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
@ -1569,9 +1624,11 @@ export class DynamicFormService {
componentId: layout.component_id,
componentType: properties?.componentType,
actionType: properties?.componentConfig?.action?.type,
enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl,
enableDataflowControl:
properties?.webTypeConfig?.enableDataflowControl,
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
});
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
@ -1596,21 +1653,27 @@ export class DynamicFormService {
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
let controlResult: any;
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, {
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
formData: savedData,
});
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const executionResult = await NodeFlowExecutionService.executeFlow(
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
controlResult = {
success: executionResult.success,
message: executionResult.message,
@ -1625,15 +1688,18 @@ export class DynamicFormService {
};
} else {
// 관계 기반 제어관리 실행
console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`);
controlResult = await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
);
controlResult =
await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
@ -1690,7 +1756,7 @@ export class DynamicFormService {
): Promise<{ affectedRows: number }> {
const pool = getPool();
const client = await pool.connect();
try {
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
tableName,
@ -1708,11 +1774,13 @@ export class DynamicFormService {
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
`;
const columnResult = await client.query(columnQuery, [tableName]);
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
const hasUpdatedBy = existingColumns.includes('updated_by');
const hasUpdatedAt = existingColumns.includes('updated_at');
const hasCompanyCode = existingColumns.includes('company_code');
const existingColumns = columnResult.rows.map(
(row: any) => row.column_name
);
const hasUpdatedBy = existingColumns.includes("updated_by");
const hasUpdatedAt = existingColumns.includes("updated_at");
const hasCompanyCode = existingColumns.includes("company_code");
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
hasUpdatedBy,
@ -1909,7 +1977,8 @@ export class DynamicFormService {
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 sqlQuery = `

File diff suppressed because it is too large Load Diff

View File

@ -607,7 +607,9 @@ class NumberingRuleService {
}
const result = await pool.query(query, params);
if (result.rowCount === 0) return null;
if (result.rowCount === 0) {
return null;
}
const rule = result.rows[0];

View File

@ -2360,30 +2360,33 @@ export class ScreenManagementService {
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
const existingScreens = await client.query<{ screen_code: string; num: number }>(
`SELECT screen_code,
COALESCE(
NULLIF(
regexp_replace(screen_code, $2, '\\1'),
screen_code
)::integer,
0
) as num
FROM screen_definitions
WHERE company_code = $1
AND screen_code ~ $2
AND deleted_date IS NULL
ORDER BY num DESC
LIMIT 1`,
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
);
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
maxNumber = existingScreens.rows[0].num;
}
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode}${maxNumber}`);
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {

View File

@ -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와 동일

View File

@ -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;
```

View File

@ -581,3 +581,4 @@ const result = await executeNodeFlow(flowId, {
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`

View File

@ -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 | 역방향 조회 | 중 | 낮음 |

View File

@ -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. 체크리스트
메일 발송 기능 구현 시 확인 사항:
- [ ] 메일 계정이 등록되어 있는가?
- [ ] 메일 계정 테스트 발송이 성공하는가?
- [ ] 제어관리에 메일 발송 플로우가 생성되어 있는가?
- [ ] 테이블 소스 노드의 데이터 소스가 올바르게 설정되어 있는가?
- [ ] 메일 발송 노드에서 계정이 선택되어 있는가?
- [ ] 수신자 컴포넌트 사용 시 필드명이 일치하는가?
- [ ] 변수명이 테이블 컬럼명과 일치하는가?
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
&gt; &gt; . ( {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"> &gt; / &gt; // &gt; //</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"> &gt; &gt; ( )</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext";
interface RoleDetailManagementProps {
roleId: string;
@ -25,6 +26,7 @@ interface RoleDetailManagementProps {
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
const { user: currentUser } = useAuth();
const router = useRouter();
const { refreshMenus } = useMenu();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
await refreshMenus();
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers]);
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
await refreshMenus();
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions]);
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
if (isLoading) {
return (

View File

@ -296,13 +296,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
element.subtype === "custom-metric-v2" ||
element.subtype === "risk-alert-v2";
// 리스트 위젯이 단일 데이터 소스 UI를 사용하는 경우, dataSource를 dataSources로 변환
let finalDataSources = dataSources;
if (isMultiDataSourceWidget && element.subtype === "list-v2" && dataSources.length === 0 && dataSource.endpoint) {
// 단일 데이터 소스가 설정되어 있으면 dataSources 배열로 변환
finalDataSources = [dataSource];
}
// chartConfig 구성 (위젯 타입별로 다르게 처리)
let finalChartConfig = { ...chartConfig };
if (isMultiDataSourceWidget) {
finalChartConfig = {
...finalChartConfig,
dataSources: dataSources,
dataSources: finalDataSources,
};
}
@ -325,7 +332,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
...(isMultiDataSourceWidget
? {
dataSources: dataSources,
dataSources: finalDataSources,
}
: {}),
}

View File

@ -137,9 +137,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
}
updates.type = "api"; // ⭐ 중요: type을 api로 명시
updates.method = "GET"; // 기본 메서드
updates.method = (connection.default_method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE") || "GET"; // 커넥션에 설정된 메서드 사용
updates.headers = headers;
updates.queryParams = queryParams;
// Request Body가 있으면 적용
if (connection.default_body) {
updates.body = connection.default_body;
}
// 외부 커넥션 ID 저장 (백엔드에서 인증 정보 조회용)
updates.externalConnectionId = connection.id;
console.log("최종 업데이트:", updates);
onChange(updates);
@ -254,6 +263,19 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
}
});
// 요청 메서드 결정
const requestMethod = dataSource.method || "GET";
// Request Body 파싱 (POST, PUT, PATCH인 경우)
let requestBody: any = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && dataSource.body) {
try {
requestBody = JSON.parse(dataSource.body);
} catch {
throw new Error("Request Body가 올바른 JSON 형식이 아닙니다");
}
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
@ -262,9 +284,11 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
},
body: JSON.stringify({
url: dataSource.endpoint,
method: "GET",
method: requestMethod,
headers: headers,
queryParams: params,
body: requestBody,
externalConnectionId: dataSource.externalConnectionId, // DB 토큰 등 인증 정보 조회용
}),
});
@ -314,10 +338,23 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
if (dataSource.jsonPath) {
const paths = dataSource.jsonPath.split(".");
for (const path of paths) {
if (data && typeof data === "object" && path in data) {
data = data[path];
// 배열인 경우 인덱스 접근, 객체인 경우 키 접근
if (data === null || data === undefined) {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다 (null/undefined)`);
}
if (Array.isArray(data)) {
// 배열인 경우 숫자 인덱스로 접근 시도
const index = parseInt(path);
if (!isNaN(index) && index >= 0 && index < data.length) {
data = data[index];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 배열 인덱스 "${path}"를 찾을 수 없습니다`);
}
} else if (typeof data === "object" && path in data) {
data = (data as Record<string, any>)[path];
} else {
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 "${path}" 키를 찾을 수 없습니다`);
}
}
}
@ -331,6 +368,16 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
// 컬럼 추출 및 타입 분석
const firstRow = rows[0];
// firstRow가 null이거나 객체가 아닌 경우 처리
if (firstRow === null || firstRow === undefined) {
throw new Error("API 응답의 첫 번째 행이 비어있습니다");
}
if (typeof firstRow !== "object" || Array.isArray(firstRow)) {
throw new Error("API 응답 데이터가 올바른 객체 형식이 아닙니다");
}
const columns = Object.keys(firstRow);
// 각 컬럼의 타입 분석
@ -400,21 +447,54 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
<p className="text-[11px] text-muted-foreground"> REST API </p>
</div>
{/* API URL */}
{/* HTTP 메서드 및 API URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">API URL *</Label>
<Input
type="url"
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 text-xs"
/>
<div className="flex gap-2">
<Select
value={dataSource.method || "GET"}
onValueChange={(value) => onChange({ method: value as "GET" | "POST" | "PUT" | "PATCH" | "DELETE" })}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="GET" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="GET" className="text-xs">GET</SelectItem>
<SelectItem value="POST" className="text-xs">POST</SelectItem>
<SelectItem value="PUT" className="text-xs">PUT</SelectItem>
<SelectItem value="PATCH" className="text-xs">PATCH</SelectItem>
<SelectItem value="DELETE" className="text-xs">DELETE</SelectItem>
</SelectContent>
</Select>
<Input
type="url"
placeholder="https://api.example.com/data"
value={dataSource.endpoint || ""}
onChange={(e) => onChange({ endpoint: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
URL base_url ( base_url )
</p>
</div>
{/* Request Body (POST, PUT, PATCH인 경우) */}
{["POST", "PUT", "PATCH"].includes(dataSource.method || "") && (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground">Request Body (JSON)</Label>
<textarea
placeholder='{"key": "value"}'
value={dataSource.body || ""}
onChange={(e) => onChange({ body: e.target.value })}
className="h-24 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<p className="text-[11px] text-muted-foreground">
JSON
</p>
</div>
)}
{/* 쿼리 파라미터 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@ -544,6 +624,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
</p>
</div>
{/* 자동 새로고침 (HTTP Polling) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-foreground"> </Label>
<Select
value={(dataSource.refreshInterval || 0).toString()}
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="0" className="text-xs"> ()</SelectItem>
<SelectItem value="5" className="text-xs">5</SelectItem>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">1</SelectItem>
<SelectItem value="300" className="text-xs">5</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
API를
</p>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>

View File

@ -285,16 +285,46 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
});
}
// 요청 메서드 (기본값: GET)
const requestMethod = element.dataSource.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && element.dataSource.body) {
try {
requestBody = typeof element.dataSource.body === "string"
? JSON.parse(element.dataSource.body)
: element.dataSource.body;
} catch {
requestBody = element.dataSource.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (element.dataSource.headers && Array.isArray(element.dataSource.headers)) {
element.dataSource.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (element.dataSource.headers && typeof element.dataSource.headers === "object") {
Object.assign(headersObj, element.dataSource.headers);
}
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
url: element.dataSource.endpoint,
method: "GET",
headers: element.dataSource.headers || {},
method: requestMethod,
headers: headersObj,
queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: element.dataSource.externalConnectionId,
}),
});

View File

@ -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;

View File

@ -316,12 +316,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => {
console.log("[ListTestWidget] dataSources:", dataSources);
if (!dataSources || dataSources.length === 0) {
// console.log("⚠️ 데이터 소스가 없습니다.");
console.log("[ListTestWidget] 데이터 소스가 없습니다.");
return;
}
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
console.log(`[ListTestWidget] ${dataSources.length}개의 데이터 소스 로딩 시작...`, dataSources[0]);
setIsLoading(true);
setError(null);
@ -412,18 +414,52 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
});
}
// 요청 메서드 (기본값: GET)
const requestMethod = source.method || "GET";
// 요청 body (POST, PUT, PATCH인 경우)
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(requestMethod) && source.body) {
try {
// body가 문자열이면 JSON 파싱 시도
requestBody = typeof source.body === "string" ? JSON.parse(source.body) : source.body;
} catch {
// 파싱 실패하면 문자열 그대로 사용
requestBody = source.body;
}
}
// headers를 KeyValuePair[] 에서 객체로 변환
const headersObj: Record<string, string> = {};
if (source.headers && Array.isArray(source.headers)) {
source.headers.forEach((h: any) => {
if (h.key && h.value) {
headersObj[h.key] = h.value;
}
});
} else if (source.headers && typeof source.headers === "object") {
// 이미 객체인 경우 그대로 사용
Object.assign(headersObj, source.headers);
}
const requestPayload = {
url: source.endpoint,
method: requestMethod,
headers: headersObj,
queryParams: Object.fromEntries(params),
body: requestBody,
externalConnectionId: source.externalConnectionId,
};
console.log("[ListTestWidget] API 요청:", requestPayload);
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
url: source.endpoint,
method: "GET",
headers: source.headers || {},
queryParams: Object.fromEntries(params),
}),
body: JSON.stringify(requestPayload),
});
if (!response.ok) {

View File

@ -18,7 +18,6 @@ import { ValidationNotification } from "./ValidationNotification";
import { FlowToolbar } from "./FlowToolbar";
import { TableSourceNode } from "./nodes/TableSourceNode";
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
import { ConditionNode } from "./nodes/ConditionNode";
import { InsertActionNode } from "./nodes/InsertActionNode";
import { UpdateActionNode } from "./nodes/UpdateActionNode";
@ -26,9 +25,13 @@ import { DeleteActionNode } from "./nodes/DeleteActionNode";
import { UpsertActionNode } from "./nodes/UpsertActionNode";
import { DataTransformNode } from "./nodes/DataTransformNode";
import { AggregateNode } from "./nodes/AggregateNode";
import { FormulaTransformNode } from "./nodes/FormulaTransformNode";
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
import { CommentNode } from "./nodes/CommentNode";
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 type { FlowValidation } from "@/lib/utils/flowValidation";
@ -38,16 +41,20 @@ const nodeTypes = {
tableSource: TableSourceNode,
externalDBSource: ExternalDBSourceNode,
restAPISource: RestAPISourceNode,
referenceLookup: ReferenceLookupNode,
// 변환/조건
condition: ConditionNode,
dataTransform: DataTransformNode,
aggregate: AggregateNode,
// 액션
formulaTransform: FormulaTransformNode,
// 데이터 액션
insertAction: InsertActionNode,
updateAction: UpdateActionNode,
deleteAction: DeleteActionNode,
upsertAction: UpsertActionNode,
// 외부 연동 액션
emailAction: EmailActionNode,
scriptAction: ScriptActionNode,
httpRequestAction: HttpRequestActionNode,
// 유틸리티
comment: CommentNode,
log: LogNode,
@ -248,7 +255,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
defaultData.responseMapping = "";
}
// 액션 노드의 경우 targetType 기본값 설정
// 데이터 액션 노드의 경우 targetType 기본값 설정
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
defaultData.targetType = "internal"; // 기본값: 내부 DB
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 = {
id: `node_${Date.now()}`,
type,

View File

@ -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;

View File

@ -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$/, "");
}

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -8,7 +8,6 @@ import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { TableSourceProperties } from "./properties/TableSourceProperties";
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
import { InsertActionProperties } from "./properties/InsertActionProperties";
import { ConditionProperties } from "./properties/ConditionProperties";
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
@ -17,9 +16,13 @@ import { ExternalDBSourceProperties } from "./properties/ExternalDBSourcePropert
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
import { DataTransformProperties } from "./properties/DataTransformProperties";
import { AggregateProperties } from "./properties/AggregateProperties";
import { FormulaTransformProperties } from "./properties/FormulaTransformProperties";
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
import { CommentProperties } from "./properties/CommentProperties";
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";
export function PropertiesPanel() {
@ -29,21 +32,21 @@ export function PropertiesPanel() {
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden'
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{/* 헤더 */}
<div
style={{
flexShrink: 0,
height: '64px'
}}
<div
style={{
flexShrink: 0,
height: "64px",
}}
className="flex items-center justify-between border-b bg-white p-4"
>
<div>
@ -58,12 +61,12 @@ export function PropertiesPanel() {
</div>
{/* 내용 - 스크롤 가능 영역 */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden'
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
}}
>
{selectedNodes.length === 0 ? (
@ -99,9 +102,6 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "tableSource":
return <TableSourceProperties nodeId={node.id} data={node.data} />;
case "referenceLookup":
return <ReferenceLookupProperties nodeId={node.id} data={node.data} />;
case "insertAction":
return <InsertActionProperties nodeId={node.id} data={node.data} />;
@ -126,6 +126,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "aggregate":
return <AggregateProperties nodeId={node.id} data={node.data} />;
case "formulaTransform":
return <FormulaTransformProperties nodeId={node.id} data={node.data} />;
case "restAPISource":
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
@ -135,6 +138,15 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "log":
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:
return (
<div className="p-4">
@ -161,15 +173,18 @@ function getNodeTypeLabel(type: NodeType): string {
tableSource: "테이블 소스",
externalDBSource: "외부 DB 소스",
restAPISource: "REST API 소스",
referenceLookup: "참조 조회",
condition: "조건 분기",
fieldMapping: "필드 매핑",
dataTransform: "데이터 변환",
aggregate: "집계",
formulaTransform: "수식 변환",
insertAction: "INSERT 액션",
updateAction: "UPDATE 액션",
deleteAction: "DELETE 액션",
upsertAction: "UPSERT 액션",
emailAction: "메일 발송",
scriptAction: "스크립트 실행",
httpRequestAction: "HTTP 요청",
comment: "주석",
log: "로그",
};

View File

@ -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 />
&gt; &gt; .
</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> &gt; </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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -236,7 +236,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
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") {
console.log("✅ 집계 노드 발견");
const nodeData = node.data as any;
@ -268,7 +292,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
if (outputFieldName) {
fields.push({
name: outputFieldName,
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
sourcePath: currentPath,
});
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -212,7 +212,27 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
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") {
const nodeData = node.data as any;
@ -240,7 +260,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
if (outputFieldName) {
fields.push({
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") {
foundRestAPI = true;
const responseFields = (node.data as any).responseFields;

View File

@ -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") {
const nodeData = node.data as any;
@ -240,7 +260,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
if (outputFieldName) {
fields.push({
name: outputFieldName,
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
});
}
});

View File

@ -32,14 +32,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "source",
color: "#10B981", // 초록색
},
{
type: "referenceLookup",
label: "참조 조회",
icon: "",
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
category: "source",
color: "#A855F7", // 보라색
},
// ========================================================================
// 변환/조건
@ -68,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "transform",
color: "#A855F7", // 보라색
},
{
type: "formulaTransform",
label: "수식 변환",
icon: "",
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
category: "transform",
color: "#F97316", // 오렌지색
},
// ========================================================================
// 액션
@ -105,6 +105,34 @@ export const NODE_PALETTE: NodePaletteItem[] = [
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",
label: "액션",
label: "데이터 액션",
icon: "",
},
{
id: "external",
label: "외부 연동",
icon: "",
},
{

View File

@ -166,18 +166,28 @@ export default function CopyScreenModal({
// linkedScreens 로딩이 완료되면 화면 코드 생성
useEffect(() => {
// 모달 화면들의 코드가 모두 설정되었는지 확인
const allModalCodesSet = linkedScreens.length === 0 ||
linkedScreens.every(screen => screen.newScreenCode);
console.log("🔍 코드 생성 조건 체크:", {
targetCompanyCode,
loadingLinkedScreens,
screenCode,
linkedScreensCount: linkedScreens.length,
allModalCodesSet,
});
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
// 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때)
const needsCodeGeneration = targetCompanyCode &&
!loadingLinkedScreens &&
(!screenCode || (linkedScreens.length > 0 && !allModalCodesSet));
if (needsCodeGeneration) {
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
generateScreenCodes();
}
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
}, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]);
// 회사 목록 조회
const loadCompanies = async () => {

View File

@ -55,6 +55,83 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
/**
* 🔗 ( )
*/
interface CascadingDropdownInFormProps {
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
config,
parentValue,
value,
onChange,
placeholder,
className,
}) => {
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo {
@ -1434,6 +1511,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "select":
case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfig?.enabled) {
const parentValue = editFormData[cascadingConfig.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
@ -1670,9 +1766,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "select":
case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfigAdd?.enabled) {
const parentValueAdd = addFormData[cascadingConfigAdd.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfigAdd}
parentValue={parentValueAdd}
value={value}
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) {
return (
<div>
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
@ -1680,7 +1795,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<SelectValue placeholder={commonProps.placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option: any, index: number) => (
{optionsAdd.map((option: any, index: number) => (
<SelectItem key={index} value={option.value || option}>
{option.label || option}
</SelectItem>
@ -1696,20 +1811,20 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "radio":
// 상세 설정에서 옵션 목록 가져오기
const radioOptions = detailSettings?.options || [];
const defaultValue = detailSettings?.defaultValue;
const radioOptionsAdd = detailSettings?.options || [];
const defaultValueAdd = detailSettings?.defaultValue;
// 추가 모달에서는 기본값이 있으면 초기값으로 설정
if (radioOptions.length > 0) {
if (radioOptionsAdd.length > 0) {
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
if (!value && defaultValue) {
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0);
if (!value && defaultValueAdd) {
setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
}
return (
<div>
<div className="space-y-2">
{radioOptions.map((option: any, index: number) => (
{radioOptionsAdd.map((option: any, index: number) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"

View File

@ -9,12 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CalendarIcon, File, Upload, X } from "lucide-react";
import { CalendarIcon, File, Upload, X, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import {
ComponentData,
WidgetComponent,
@ -49,6 +51,96 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
/**
* 🔗
* InteractiveScreenViewer
*/
interface CascadingDropdownWrapperProps {
/** 직접 설정 방식 */
config?: CascadingDropdownConfig;
/** 공통 관리 방식 (관계 코드) */
relationCode?: string;
/** 부모 필드명 (relationCode 사용 시 필요) */
parentFieldName?: string;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
config,
relationCode,
parentFieldName,
parentValue,
value,
onChange,
placeholder,
disabled,
required,
}) => {
const { options, loading, error, relationConfig } = useCascadingDropdown({
config,
relationCode,
parentValue,
});
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
const effectiveConfig = config || relationConfig;
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return effectiveConfig?.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return effectiveConfig?.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return effectiveConfig?.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = disabled || !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className="h-full w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
interface InteractiveScreenViewerProps {
component: ComponentData;
allComponents: ComponentData[];
@ -697,10 +789,55 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
searchable: config?.searchable,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
cascading: config?.cascading,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
if (config?.cascadingRelationCode && config?.cascadingParentField) {
const parentFieldValue = formData[config.cascadingParentField];
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
relationCode: config.cascadingRelationCode,
parentField: config.cascadingParentField,
parentValue: parentFieldValue,
});
return applyStyles(
<CascadingDropdownWrapper
relationCode={config.cascadingRelationCode}
parentFieldName={config.cascadingParentField}
parentValue={parentFieldValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
if (config?.cascading?.enabled) {
const cascadingConfig = config.cascading;
const parentValue = formData[cascadingConfig.parentField];
return applyStyles(
<CascadingDropdownWrapper
config={cascadingConfig}
parentValue={parentValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 일반 Select
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },

View File

@ -5,14 +5,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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";
interface StyleEditorProps {
style: ComponentStyle;
onStyleChange: (style: ComponentStyle) => void;
className?: string;
}
import { ColorPickerWithTransparent } from "./common/ColorPickerWithTransparent";
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
@ -80,28 +75,18 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="borderColor" className="text-xs font-medium">
</Label>
<div className="flex gap-1">
<Input
id="borderColor"
type="color"
value={localStyle.borderColor || "#000000"}
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
className="h-6 w-12 p-1"
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>
<ColorPickerWithTransparent
id="borderColor"
value={localStyle.borderColor}
onChange={(value) => handleStyleChange("borderColor", value)}
defaultColor="#e5e7eb"
placeholder="#e5e7eb"
/>
</div>
<div className="space-y-1">
<Label htmlFor="borderRadius" className="text-xs font-medium">
@ -132,23 +117,13 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
<Label htmlFor="backgroundColor" className="text-xs font-medium">
</Label>
<div className="flex gap-1">
<Input
id="backgroundColor"
type="color"
value={localStyle.backgroundColor || "#ffffff"}
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
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"
className="h-6 flex-1 text-xs"
/>
</div>
<ColorPickerWithTransparent
id="backgroundColor"
value={localStyle.backgroundColor}
onChange={(value) => handleStyleChange("backgroundColor", value)}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
</div>
<div className="space-y-1">
@ -178,28 +153,18 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
</div>
<Separator className="my-1.5" />
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="color" className="text-xs font-medium">
</Label>
<div className="flex gap-1">
<Input
id="color"
type="color"
value={localStyle.color || "#000000"}
onChange={(e) => handleStyleChange("color", e.target.value)}
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"
className="h-6 flex-1 text-xs"
/>
</div>
<ColorPickerWithTransparent
id="color"
value={localStyle.color}
onChange={(value) => handleStyleChange("color", value)}
defaultColor="#000000"
placeholder="#000000"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fontSize" className="text-xs font-medium">

View File

@ -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;

View File

@ -6,6 +6,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
interface CardConfigPanelProps {
component: ComponentData;
@ -93,11 +94,12 @@ export const CardConfigPanel: React.FC<CardConfigPanelProps> = ({ component, onU
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<Input
<ColorPickerWithTransparent
id="background-color"
type="color"
value={config.backgroundColor || "#ffffff"}
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
value={config.backgroundColor}
onChange={(value) => handleConfigChange("backgroundColor", value)}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
</div>

View File

@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
interface DashboardConfigPanelProps {
component: ComponentData;
@ -124,11 +125,12 @@ export const DashboardConfigPanel: React.FC<DashboardConfigPanelProps> = ({ comp
{/* 배경색 */}
<div className="space-y-2">
<Label htmlFor="background-color"></Label>
<Input
<ColorPickerWithTransparent
id="background-color"
type="color"
value={config.backgroundColor || "#f8f9fa"}
onChange={(e) => handleConfigChange("backgroundColor", e.target.value)}
value={config.backgroundColor}
onChange={(value) => handleConfigChange("backgroundColor", value)}
defaultColor="#f8f9fa"
placeholder="#f8f9fa"
/>
</div>

View File

@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ComponentData } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
interface ProgressBarConfigPanelProps {
component: ComponentData;
@ -52,11 +53,12 @@ export const ProgressBarConfigPanel: React.FC<ProgressBarConfigPanelProps> = ({
<div>
<Label htmlFor="progress-color"> </Label>
<Input
<ColorPickerWithTransparent
id="progress-color"
type="color"
value={config.color || "#3b82f6"}
onChange={(e) => onUpdateProperty("componentConfig.color", e.target.value)}
value={config.color}
onChange={(value) => onUpdateProperty("componentConfig.color", value)}
defaultColor="#3b82f6"
placeholder="#3b82f6"
/>
</div>

View File

@ -7,9 +7,12 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, ChevronDown, List } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, List, Link2, ExternalLink } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import Link from "next/link";
interface SelectOption {
label: string;
@ -38,7 +41,18 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: config.required || false,
readonly: config.readonly || false,
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
cascadingRelationCode: config.cascadingRelationCode,
cascadingParentField: config.cascadingParentField,
});
// 연쇄 드롭다운 설정 상태
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
const [selectedRelationCode, setSelectedRelationCode] = useState(config.cascadingRelationCode || "");
const [selectedParentField, setSelectedParentField] = useState(config.cascadingParentField || "");
// 연쇄 관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 새 옵션 추가용 상태
const [newOptionLabel, setNewOptionLabel] = useState("");
@ -66,6 +80,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: currentConfig.required || false,
readonly: currentConfig.readonly || false,
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
cascadingRelationCode: currentConfig.cascadingRelationCode,
});
// 입력 필드 로컬 상태도 동기화
@ -73,7 +88,34 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
placeholder: currentConfig.placeholder || "",
emptyMessage: currentConfig.emptyMessage || "",
});
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!currentConfig.cascadingRelationCode);
setSelectedRelationCode(currentConfig.cascadingRelationCode || "");
setSelectedParentField(currentConfig.cascadingParentField || "");
}, [widget.webTypeConfig]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 설정 업데이트 핸들러
const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
@ -82,6 +124,38 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", newConfig);
};
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 코드 제거
setSelectedRelationCode("");
const newConfig = { ...localConfig, cascadingRelationCode: undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택
const handleRelationSelect = (code: string) => {
setSelectedRelationCode(code);
const newConfig = { ...localConfig, cascadingRelationCode: code || undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
};
// 부모 필드 선택
const handleParentFieldChange = (field: string) => {
setSelectedParentField(field);
const newConfig = { ...localConfig, cascadingParentField: field || undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
};
// 옵션 추가
const addOption = () => {
if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
@ -167,6 +241,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("options", defaultOptionSets[setName]);
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
return (
<Card>
<CardHeader>
@ -238,23 +315,122 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 기본 옵션 세트 */}
{/* 연쇄 드롭다운 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
/
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
</Button>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedRelationCode}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
{/* 부모 필드 설정 */}
{selectedRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Input
value={selectedParentField}
onChange={(e) => handleParentFieldChange(e.target.value)}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && (
<div className="bg-muted/50 space-y-2 rounded-md p-2">
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
<span className="text-muted-foreground"> ({selectedRelation.parent_value_column})</span>
</div>
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
<span className="text-muted-foreground">
{" "}({selectedRelation.child_filter_column} {selectedRelation.child_value_column})
</span>
</div>
{selectedRelation.description && (
<div className="text-muted-foreground text-xs">{selectedRelation.description}</div>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
{/* 옵션 관리 */}
{/* 기본 옵션 세트 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
/
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
</Button>
</div>
</div>
)}
{/* 옵션 관리 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
@ -337,8 +513,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
</div>
)}
{/* 기본값 설정 */}
{/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"></h4>
@ -361,6 +539,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</select>
</div>
</div>
)}
{/* 상태 설정 */}
<div className="space-y-3">
@ -395,7 +574,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 미리보기 */}
{/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"></h4>
<div className="bg-muted/50 rounded-md border p-3">
@ -422,11 +602,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
};
SelectConfigPanel.displayName = "SelectConfigPanel";

View File

@ -1179,6 +1179,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
return currentTable?.columns || [];
})()}
tables={tables} // 전체 테이블 목록 전달
allComponents={components} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => {
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지

View File

@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
import { GridSettings, ScreenResolution } from "@/types/screen";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
interface GridPanelProps {
gridSettings: GridSettings;
@ -105,20 +106,13 @@ export const GridPanel: React.FC<GridPanelProps> = ({
<Label htmlFor="gridColor" className="text-xs font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
<div className="mt-1">
<ColorPickerWithTransparent
id="gridColor"
type="color"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
className="h-8 w-12 rounded border p-1"
/>
<Input
type="text"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
value={gridSettings.gridColor}
onChange={(value) => updateSetting("gridColor", value)}
defaultColor="#d1d5db"
placeholder="#d1d5db"
className="flex-1 text-xs"
/>
</div>
</div>

View File

@ -31,6 +31,7 @@ import {
getBaseInputType,
getDefaultDetailType,
} from "@/types/input-type-mapping";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
// DataTableConfigPanel을 위한 안정화된 래퍼 컴포넌트
const DataTableConfigPanelWrapper: React.FC<{
@ -1092,17 +1093,18 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
<Label htmlFor="labelColor" className="text-sm font-medium">
</Label>
<Input
id="labelColor"
type="color"
value={localInputs.labelColor}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, labelColor: newValue }));
onUpdateProperty("style.labelColor", newValue);
}}
className="mt-1 h-8"
/>
<div className="mt-1">
<ColorPickerWithTransparent
id="labelColor"
value={localInputs.labelColor}
onChange={(value) => {
setLocalInputs((prev) => ({ ...prev, labelColor: value || "" }));
onUpdateProperty("style.labelColor", value);
}}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
<div>

View File

@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
import { LayoutRow } from "@/types/grid-system";
import { GapPreset, GAP_PRESETS } from "@/lib/constants/columnSpans";
import { Rows, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter } from "lucide-react";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
interface RowSettingsPanelProps {
row: LayoutRow;
@ -224,26 +225,12 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
{/* 배경색 */}
<div className="space-y-3">
<Label className="text-sm font-medium"></Label>
<div className="flex gap-2">
<Input
type="color"
value={row.backgroundColor || "#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"
className="flex-1"
/>
{row.backgroundColor && (
<Button variant="ghost" size="sm" onClick={() => onUpdateRow({ backgroundColor: undefined })}>
</Button>
)}
</div>
<ColorPickerWithTransparent
value={row.backgroundColor}
onChange={(value) => onUpdateRow({ backgroundColor: value })}
defaultColor="#ffffff"
placeholder="#ffffff"
/>
</div>
</div>
);

View File

@ -48,6 +48,7 @@ import {
import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent";
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
@ -365,6 +366,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
/>
</Suspense>
</div>
@ -603,13 +606,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.componentConfig?.customColor || "#f0f0f0"}
onChange={(e) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", e.target.value);
<ColorPickerWithTransparent
value={selectedComponent.componentConfig?.customColor}
onChange={(value) => {
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", value);
}}
className="h-9"
defaultColor="#f0f0f0"
placeholder="#f0f0f0"
/>
</div>
)}
@ -882,12 +885,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="color"
value={selectedComponent.style?.labelColor || "#212121"}
onChange={(e) => handleUpdate("style.labelColor", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
className="text-xs"
<ColorPickerWithTransparent
value={selectedComponent.style?.labelColor}
onChange={(value) => handleUpdate("style.labelColor", value)}
defaultColor="#212121"
placeholder="#212121"
/>
</div>
</div>
@ -1074,6 +1076,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tableColumns={currentTable?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => {
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => {
@ -1237,6 +1241,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tableColumns={currentTable?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
// 전체 componentConfig를 업데이트

View File

@ -1,32 +1,23 @@
"use client"
"use client";
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
className,
)}
{...props}
/>
)
);
}
function CommandDialog({
@ -37,10 +28,10 @@ function CommandDialog({
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
@ -48,127 +39,92 @@ function CommandDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
/>
</div>
)
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain", className)}
onWheel={(e) => {
e.stopPropagation();
}}
{...props}
/>
)
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
className,
)}
{...props}
/>
)
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
);
}
export {
@ -181,4 +137,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

View File

@ -16,6 +16,31 @@ interface AuthProviderProps {
children: React.ReactNode;
}
/**
*
* WebView,
*/
function isMobileEnvironment(): boolean {
if (typeof window === "undefined") return false;
const userAgent = navigator.userAgent.toLowerCase();
// 모바일 기기 감지
const isMobileDevice = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
// WebView 감지 (앱 내 브라우저)
const isWebView =
/wv/.test(userAgent) || // Android WebView
/webview/.test(userAgent) ||
(window as unknown as { webkit?: unknown }).webkit !== undefined || // iOS WKWebView
/fb_iab|fban|fbav/.test(userAgent) || // Facebook 앱 내 브라우저
/instagram/.test(userAgent) || // Instagram 앱 내 브라우저
/kakaotalk/.test(userAgent) || // 카카오톡 앱 내 브라우저
/naver/.test(userAgent); // 네이버 앱 내 브라우저
return isMobileDevice || isWebView;
}
/**
*
*
@ -29,12 +54,20 @@ export function AuthProvider({ children }: AuthProviderProps) {
// 세션 매니저 초기화 및 정리
useEffect(() => {
if (isLoggedIn) {
// 모바일 환경 여부에 따라 타임아웃 시간 조정
const isMobile = isMobileEnvironment();
// 모바일: 24시간 (JWT 토큰 만료 시간과 동일), 데스크톱: 30분
const maxInactiveTime = isMobile ? 86400000 : 1800000;
// 모바일: 1시간 전 경고, 데스크톱: 5분 전 경고
const warningTimeConfig = isMobile ? 3600000 : 300000;
// 세션 매니저 초기화
const manager = initSessionManager(
{
checkInterval: 60000, // 1분마다 체크
warningTime: 300000, // 5분 전 경고
maxInactiveTime: 1800000, // 30분 비활성 시 만료
warningTime: warningTimeConfig,
maxInactiveTime: maxInactiveTime,
},
{
onWarning: (remainingTime: number) => {

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -317,6 +317,11 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
return Promise.reject(error);
}
// 다른 에러들은 기존처럼 상세 로그 출력
console.error("API 응답 오류:", {
status: status,
@ -324,7 +329,6 @@ apiClient.interceptors.response.use(
url: url,
data: error.response?.data,
message: error.message,
headers: error.config?.headers,
});
// 401 에러 처리

View File

@ -109,11 +109,24 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
export async function previewNumberingCode(
ruleId: string
): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
return { success: false, error: "채번 규칙 ID가 설정되지 않았습니다" };
}
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" };
}
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "코드 미리보기 실패" };
const errorMessage =
error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
"코드 미리보기 실패";
return { success: false, error: errorMessage };
}
}

View File

@ -60,6 +60,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
groupedData?: Record<string, any>[];
}
/**
@ -98,6 +101,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
flowSelectedData,
flowSelectedStepId,
allComponents, // 🆕 같은 화면의 모든 컴포넌트
groupedData, // 🆕 부모창에서 전달된 그룹 데이터
...props
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@ -807,9 +811,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
// 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
// 🆕 선택된 데이터 우선순위:
// 1. selectedRowsData (테이블에서 직접 선택)
// 2. groupedData (부모창에서 모달로 전달된 데이터)
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
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 {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;

View File

@ -83,6 +83,9 @@ import "./rack-structure/RackStructureRenderer"; // 창고 렉 위치 일괄 생
// 🆕 세금계산서 관리 컴포넌트
import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, 작성, 발행, 취소
// 🆕 메일 수신자 선택 컴포넌트
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
/**
*
*/

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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";

View File

@ -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;
}

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -293,6 +293,9 @@ export function ModalRepeaterTableComponent({
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
// 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID)
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
@ -409,6 +412,193 @@ export function ModalRepeaterTableComponent({
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
/**
*
*
*/
const handleDataSourceChange = async (columnField: string, optionId: string) => {
console.log(`🔄 데이터 소스 변경: ${columnField}${optionId}`);
// 활성화 상태 업데이트
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
}));
// 해당 컬럼 찾기
const column = columns.find((col) => col.field === columnField);
if (!column?.dynamicDataSource?.enabled) {
console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`);
return;
}
// 선택된 옵션 찾기
const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId);
if (!option) {
console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`);
return;
}
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
localValue.map(async (row, index) => {
try {
const newValue = await fetchDynamicValue(option, row);
console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`);
return {
...row,
[columnField]: newValue,
};
} catch (error) {
console.error(` ❌ 행 ${index} 조회 실패:`, error);
return row;
}
})
);
// 계산 필드 업데이트 후 데이터 반영
const calculatedData = calculateAll(updatedData);
handleChange(calculatedData);
};
/**
*
*/
async function fetchDynamicValue(
option: DynamicDataSourceOption,
rowData: any
): Promise<any> {
if (option.sourceType === "table" && option.tableConfig) {
// 테이블 직접 조회 (단순 조인)
const { tableName, valueColumn, joinConditions } = option.tableConfig;
const whereConditions: Record<string, any> = {};
for (const cond of joinConditions) {
const value = rowData[cond.sourceField];
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
return undefined;
}
whereConditions[cond.targetField] = value;
}
console.log(`🔍 테이블 조회: ${tableName}`, whereConditions);
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
}
return undefined;
} else if (option.sourceType === "multiTable" && option.multiTableConfig) {
// 테이블 복합 조인 (2개 이상 테이블 순차 조인)
const { joinChain, valueColumn } = option.multiTableConfig;
if (!joinChain || joinChain.length === 0) {
console.warn("⚠️ 조인 체인이 비어있습니다.");
return undefined;
}
console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`);
// 현재 값을 추적 (첫 단계는 현재 행에서 시작)
let currentValue: any = null;
let currentRow: any = null;
for (let i = 0; i < joinChain.length; i++) {
const step = joinChain[i];
const { tableName, joinCondition, outputField } = step;
// 조인 조건 값 가져오기
let fromValue: any;
if (i === 0) {
// 첫 번째 단계: 현재 행에서 값 가져오기
fromValue = rowData[joinCondition.fromField];
console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`);
} else {
// 이후 단계: 이전 조회 결과에서 값 가져오기
fromValue = currentRow?.[joinCondition.fromField] || currentValue;
console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`);
}
if (fromValue === undefined || fromValue === null) {
console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`);
return undefined;
}
// 테이블 조회
const whereConditions: Record<string, any> = {
[joinCondition.toField]: fromValue
};
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
try {
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
currentRow = response.data.data.data[0];
currentValue = outputField ? currentRow[outputField] : currentRow;
console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue });
} else {
console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`);
return undefined;
}
} catch (error) {
console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error);
return undefined;
}
}
// 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기)
const finalValue = currentRow?.[valueColumn];
console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`);
return finalValue;
} else if (option.sourceType === "api" && option.apiConfig) {
// 전용 API 호출 (복잡한 다중 조인)
const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig;
// 파라미터 빌드
const params: Record<string, any> = {};
for (const mapping of parameterMappings) {
const value = rowData[mapping.sourceField];
if (value !== undefined && value !== null) {
params[mapping.paramName] = value;
}
}
console.log(`🔍 API 호출: ${method} ${endpoint}`, params);
let response;
if (method === "POST") {
response = await apiClient.post(endpoint, params);
} else {
response = await apiClient.get(endpoint, { params });
}
if (response.data.success && response.data.data) {
// responseValueField로 값 추출 (중첩 경로 지원: "data.price")
const keys = responseValueField.split(".");
let value = response.data.data;
for (const key of keys) {
value = value?.[key];
}
return value;
}
return undefined;
}
return undefined;
}
// 초기 데이터에 계산 필드 적용
useEffect(() => {
@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
/>
{/* 항목 선택 모달 */}

View File

@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
@ -169,6 +170,10 @@ export function ModalRepeaterTableConfigPanel({
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
// 동적 데이터 소스 설정 모달
const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false);
const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState<number | null>(null);
// config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용)
useEffect(() => {
@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({
updateConfig({ calculationRules: rules });
};
// 동적 데이터 소스 설정 함수들
const openDynamicSourceModal = (columnIndex: number) => {
setEditingDynamicSourceColumnIndex(columnIndex);
setDynamicSourceModalOpen(true);
};
const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => {
const columns = [...(localConfig.columns || [])];
if (enabled) {
columns[columnIndex] = {
...columns[columnIndex],
dynamicDataSource: {
enabled: true,
options: [],
},
};
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dynamicDataSource, ...rest } = columns[columnIndex];
columns[columnIndex] = rest;
}
updateConfig({ columns });
};
const addDynamicSourceOption = (columnIndex: number) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const newOption: DynamicDataSourceOption = {
id: `option_${Date.now()}`,
label: "새 옵션",
sourceType: "table",
tableConfig: {
tableName: "",
valueColumn: "",
joinConditions: [],
},
};
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
enabled: true,
options: [...(col.dynamicDataSource?.options || []), newOption],
},
};
updateConfig({ columns });
};
const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial<DynamicDataSourceOption>) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const options = [...(col.dynamicDataSource?.options || [])];
options[optionIndex] = { ...options[optionIndex], ...updates };
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
options,
},
};
updateConfig({ columns });
};
const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
const options = [...(col.dynamicDataSource?.options || [])];
options.splice(optionIndex, 1);
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
options,
},
};
updateConfig({ columns });
};
const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => {
const columns = [...(localConfig.columns || [])];
const col = columns[columnIndex];
columns[columnIndex] = {
...col,
dynamicDataSource: {
...col.dynamicDataSource!,
defaultOptionId: optionId,
},
};
updateConfig({ columns });
};
return (
<div className="space-y-6 p-4">
{/* 소스/저장 테이블 설정 */}
@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({
)}
</div>
</div>
{/* 6. 동적 데이터 소스 설정 */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Switch
checked={col.dynamicDataSource?.enabled || false}
onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)}
/>
</div>
<p className="text-[10px] text-muted-foreground">
(: 거래처별 , )
</p>
{col.dynamicDataSource?.enabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{col.dynamicDataSource.options.length}
</span>
<Button
size="sm"
variant="outline"
onClick={() => openDynamicSourceModal(index)}
className="h-7 text-xs"
>
</Button>
</div>
{/* 옵션 미리보기 */}
{col.dynamicDataSource.options.length > 0 && (
<div className="flex flex-wrap gap-1">
{col.dynamicDataSource.options.map((opt) => (
<span
key={opt.id}
className={cn(
"text-[10px] px-2 py-0.5 rounded-full",
col.dynamicDataSource?.defaultOptionId === opt.id
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{opt.label}
{col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"}
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({
</ul>
</div>
</div>
{/* 동적 데이터 소스 설정 모달 */}
<Dialog open={dynamicSourceModalOpen} onOpenChange={setDynamicSourceModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
<span className="text-primary ml-2">
({localConfig.columns[editingDynamicSourceColumnIndex].label})
</span>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
<div className="space-y-4">
{/* 옵션 목록 */}
<div className="space-y-3">
{(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => (
<div key={option.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> {optIndex + 1}</span>
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && (
<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded"></span>
)}
</div>
<div className="flex items-center gap-1">
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && (
<Button
size="sm"
variant="ghost"
onClick={() => setDefaultDynamicSourceOption(editingDynamicSourceColumnIndex, option.id)}
className="h-6 text-[10px] px-2"
>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex)}
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 옵션 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Input
value={option.label}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })}
placeholder="예: 거래처별 단가"
className="h-8 text-xs"
/>
</div>
{/* 소스 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={option.sourceType}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
sourceType: value as "table" | "multiTable" | "api",
tableConfig: value === "table" ? { tableName: "", valueColumn: "", joinConditions: [] } : undefined,
multiTableConfig: value === "multiTable" ? { joinChain: [], valueColumn: "" } : undefined,
apiConfig: value === "api" ? { endpoint: "", parameterMappings: [], responseValueField: "" } : undefined,
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"> ( )</SelectItem>
<SelectItem value="multiTable"> (2 )</SelectItem>
<SelectItem value="api"> API </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 직접 조회 설정 */}
{option.sourceType === "table" && (
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium"> </p>
{/* 참조 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={option.tableConfig?.tableName || ""}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, tableName: value },
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 컬럼 */}
<div className="space-y-1">
<Label className="text-[10px]"> ( ) *</Label>
<ReferenceColumnSelector
referenceTable={option.tableConfig?.tableName || ""}
value={option.tableConfig?.valueColumn || ""}
onChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, valueColumn: value },
})}
/>
</div>
{/* 조인 조건 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newConditions = [...(option.tableConfig?.joinConditions || []), { sourceField: "", targetField: "" }];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(option.tableConfig?.joinConditions || []).map((cond, condIndex) => (
<div key={condIndex} className="flex items-center gap-2 p-2 bg-background rounded">
<Select
value={cond.sourceField}
onValueChange={(value) => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions[condIndex] = { ...newConditions[condIndex], sourceField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
>
<SelectTrigger className="h-7 text-[10px] flex-1">
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<ReferenceColumnSelector
referenceTable={option.tableConfig?.tableName || ""}
value={cond.targetField}
onChange={(value) => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions[condIndex] = { ...newConditions[condIndex], targetField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newConditions = [...(option.tableConfig?.joinConditions || [])];
newConditions.splice(condIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 테이블 복합 조인 설정 (2개 이상 테이블) */}
{option.sourceType === "multiTable" && (
<div className="space-y-3 p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
<div>
<p className="text-xs font-medium"> </p>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
{/* 조인 체인 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newChain: MultiTableJoinStep[] = [
...(option.multiTableConfig?.joinChain || []),
{ tableName: "", joinCondition: { fromField: "", toField: "" }, outputField: "" }
];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 시작점 안내 */}
<div className="p-2 bg-background rounded border-l-2 border-primary">
<p className="text-[10px] font-medium text-primary">시작: 현재 </p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 조인 단계들 */}
{(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => (
<div key={stepIndex} className="p-3 border rounded-md bg-background space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center text-[10px] font-bold">
{stepIndex + 1}
</div>
<span className="text-xs font-medium"> {stepIndex + 1}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain.splice(stepIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 조인할 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={step.tableName}
onValueChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = { ...newChain[stepIndex], tableName: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조인 조건 */}
<div className="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
<div className="space-y-1">
<Label className="text-[10px]">
{stepIndex === 0 ? "현재 행 필드" : "이전 단계 출력 필드"}
</Label>
{stepIndex === 0 ? (
<Select
value={step.joinCondition.fromField}
onValueChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label} ({col.field})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={step.joinCondition.fromField}
onChange={(e) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"}
className="h-8 text-xs"
/>
)}
</div>
<div className="flex items-center justify-center pb-1">
<span className="text-xs text-muted-foreground">=</span>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ReferenceColumnSelector
referenceTable={step.tableName}
value={step.joinCondition.toField}
onChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = {
...newChain[stepIndex],
joinCondition: { ...newChain[stepIndex].joinCondition, toField: value }
};
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
/>
</div>
</div>
{/* 다음 단계로 전달할 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> ()</Label>
<ReferenceColumnSelector
referenceTable={step.tableName}
value={step.outputField || ""}
onChange={(value) => {
const newChain = [...(option.multiTableConfig?.joinChain || [])];
newChain[stepIndex] = { ...newChain[stepIndex], outputField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
});
}}
/>
<p className="text-[10px] text-muted-foreground">
{stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1
? "다음 조인 단계에서 사용할 필드"
: "마지막 단계면 비워두세요"}
</p>
</div>
{/* 조인 미리보기 */}
{step.tableName && step.joinCondition.fromField && step.joinCondition.toField && (
<div className="p-2 bg-muted/50 rounded text-[10px] font-mono">
<span className="text-blue-600 dark:text-blue-400">
{stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName}
</span>
<span className="text-muted-foreground">.{step.joinCondition.fromField}</span>
<span className="mx-2 text-green-600 dark:text-green-400">=</span>
<span className="text-green-600 dark:text-green-400">{step.tableName}</span>
<span className="text-muted-foreground">.{step.joinCondition.toField}</span>
{step.outputField && (
<span className="ml-2 text-purple-600 dark:text-purple-400">
{step.outputField}
</span>
)}
</div>
)}
</div>
))}
{/* 조인 체인이 없을 때 안내 */}
{(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground mb-2">
</p>
<p className="text-[10px] text-muted-foreground">
"조인 추가"
</p>
</div>
)}
</div>
{/* 최종 값 컬럼 */}
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
<div className="space-y-1 pt-2 border-t">
<Label className="text-[10px]"> ( ) *</Label>
<ReferenceColumnSelector
referenceTable={option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName || ""}
value={option.multiTableConfig.valueColumn || ""}
onChange={(value) => {
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
multiTableConfig: { ...option.multiTableConfig!, valueColumn: value },
});
}}
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 전체 조인 경로 미리보기 */}
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
<div className="p-3 bg-muted rounded-md">
<p className="text-[10px] font-medium mb-2"> </p>
<div className="text-[10px] font-mono space-y-1">
{option.multiTableConfig.joinChain.map((step, idx) => (
<div key={idx} className="flex items-center gap-1">
{idx === 0 && (
<>
<span className="text-blue-600"></span>
<span>.{step.joinCondition.fromField}</span>
<span className="text-muted-foreground mx-1"></span>
</>
)}
<span className="text-green-600">{step.tableName}</span>
<span>.{step.joinCondition.toField}</span>
{step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && (
<>
<span className="text-muted-foreground mx-1"></span>
<span className="text-purple-600">{step.outputField}</span>
</>
)}
</div>
))}
{option.multiTableConfig.valueColumn && (
<div className="pt-1 border-t mt-1">
<span className="text-orange-600"> : </span>
<span>{option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn}</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{/* API 호출 설정 */}
{option.sourceType === "api" && (
<div className="space-y-3 p-3 bg-purple-50 dark:bg-purple-950 rounded-md border border-purple-200 dark:border-purple-800">
<p className="text-xs font-medium">API </p>
<p className="text-[10px] text-muted-foreground">
API로
</p>
{/* API 엔드포인트 */}
<div className="space-y-1">
<Label className="text-[10px]">API *</Label>
<Input
value={option.apiConfig?.endpoint || ""}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, endpoint: e.target.value },
})}
placeholder="/api/price/customer"
className="h-8 text-xs font-mono"
/>
</div>
{/* HTTP 메서드 */}
<div className="space-y-1">
<Label className="text-[10px]">HTTP </Label>
<Select
value={option.apiConfig?.method || "GET"}
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, method: value as "GET" | "POST" },
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</div>
{/* 파라미터 매핑 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newMappings = [...(option.apiConfig?.parameterMappings || []), { paramName: "", sourceField: "" }];
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => (
<div key={mapIndex} className="flex items-center gap-2 p-2 bg-background rounded">
<Input
value={mapping.paramName}
onChange={(e) => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
placeholder="파라미터명"
className="h-7 text-[10px] flex-1"
/>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={mapping.sourceField}
onValueChange={(value) => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings[mapIndex] = { ...newMappings[mapIndex], sourceField: value };
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
>
<SelectTrigger className="h-7 text-[10px] flex-1">
<SelectValue placeholder="현재 행 필드" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
newMappings.splice(mapIndex, 1);
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 응답 값 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Input
value={option.apiConfig?.responseValueField || ""}
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
apiConfig: { ...option.apiConfig!, responseValueField: e.target.value },
})}
placeholder="price (또는 data.price)"
className="h-8 text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">
API ( 지원: data.price)
</p>
</div>
</div>
)}
</div>
))}
{/* 옵션 추가 버튼 */}
<Button
variant="outline"
onClick={() => addDynamicSourceOption(editingDynamicSourceColumnIndex)}
className="w-full h-10"
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 안내 */}
<div className="p-3 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-1"> </p>
<ul className="space-y-1 text-[10px]">
<li>- <strong> </strong>: customer_item_price </li>
<li>- <strong> </strong>: item_info </li>
<li>- <strong> </strong>: API로 </li>
</ul>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setDynamicSourceModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Trash2, ChevronDown, Check } from "lucide-react";
import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
@ -14,6 +15,9 @@ interface RepeaterTableProps {
onDataChange: (newData: any[]) => void;
onRowChange: (index: number, newRow: any) => void;
onRowDelete: (index: number) => void;
// 동적 데이터 소스 관련
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
}
export function RepeaterTable({
@ -22,11 +26,16 @@ export function RepeaterTable({
onDataChange,
onRowChange,
onRowDelete,
activeDataSources = {},
onDataSourceChange,
}: RepeaterTableProps) {
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
field: string;
} | null>(null);
// 동적 데이터 소스 Popover 열림 상태
const [openPopover, setOpenPopover] = useState<string | null>(null);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@ -144,16 +153,79 @@ export function RepeaterTable({
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
{columns.map((col) => (
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null;
return (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-primary transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</>
)}
</th>
))}
);
})}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>

View File

@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps {
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
sourceSearchFields?: string[]; // 검색 가능한 필드들
// 🆕 저장 대상 테이블 설정
// 저장 대상 테이블 설정
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
// 모달 설정
@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps {
calculationRules?: CalculationRule[]; // 자동 계산 규칙
// 데이터
value: any[]; // 현재 추가된 항목들
onChange: (newData: any[]) => void; // 데이터 변경 콜백
value: Record<string, unknown>[]; // 현재 추가된 항목들
onChange: (newData: Record<string, unknown>[]) => void; // 데이터 변경 콜백
// 중복 체크
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
// 필터링
filterCondition?: Record<string, any>;
filterCondition?: Record<string, unknown>;
companyCode?: string;
// 스타일
@ -47,11 +47,92 @@ export interface RepeaterColumnConfig {
calculated?: boolean; // 계산 필드 여부
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
defaultValue?: any; // 기본값
defaultValue?: string | number | boolean; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
// 🆕 컬럼 매핑 설정
// 컬럼 매핑 설정
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
// 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환)
dynamicDataSource?: DynamicDataSourceConfig;
}
/**
*
*
* : 거래처별 , ,
*/
export interface DynamicDataSourceConfig {
enabled: boolean;
options: DynamicDataSourceOption[];
defaultOptionId?: string; // 기본 선택 옵션 ID
}
/**
*
* /API에서
*/
export interface DynamicDataSourceOption {
id: string;
label: string; // 표시 라벨 (예: "거래처별 단가")
// 조회 방식
sourceType: "table" | "multiTable" | "api";
// 테이블 직접 조회 (단순 조인 - 1개 테이블)
tableConfig?: {
tableName: string; // 참조 테이블명
valueColumn: string; // 가져올 값 컬럼
joinConditions: {
sourceField: string; // 현재 행의 필드
targetField: string; // 참조 테이블의 필드
}[];
};
// 테이블 복합 조인 (2개 이상 테이블 조인)
multiTableConfig?: {
// 조인 체인 정의 (순서대로 조인)
joinChain: MultiTableJoinStep[];
// 최종적으로 가져올 값 컬럼 (마지막 테이블에서)
valueColumn: string;
};
// 전용 API 호출 (복잡한 다중 조인)
apiConfig?: {
endpoint: string; // API 엔드포인트 (예: "/api/price/customer")
method?: "GET" | "POST"; // HTTP 메서드 (기본: GET)
parameterMappings: {
paramName: string; // API 파라미터명
sourceField: string; // 현재 행의 필드
}[];
responseValueField: string; // 응답에서 값을 가져올 필드
};
}
/**
*
* : item_info.item_number customer_item.item_code customer_item.id customer_item_price.customer_item_id
*/
export interface MultiTableJoinStep {
// 조인할 테이블
tableName: string;
// 조인 조건
joinCondition: {
// 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드)
fromField: string;
// 이 테이블의 필드
toField: string;
};
// 다음 단계로 전달할 필드 (다음 조인에 사용)
outputField?: string;
// 추가 필터 조건 (선택사항)
additionalFilters?: {
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
value: string | number | boolean;
// 값이 현재 행에서 오는 경우
valueFromField?: string;
}[];
}
/**
@ -101,11 +182,10 @@ export interface ItemSelectionModalProps {
sourceColumns: string[];
sourceSearchFields?: string[];
multiSelect?: boolean;
filterCondition?: Record<string, any>;
filterCondition?: Record<string, unknown>;
modalTitle: string;
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
alreadySelected: Record<string, unknown>[]; // 이미 선택된 항목들 (중복 방지용)
uniqueField?: string;
onSelect: (items: any[]) => void;
onSelect: (items: Record<string, unknown>[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
}

View File

@ -55,7 +55,8 @@ export function RepeatScreenModalComponent({
...props
}: RepeatScreenModalComponentProps) {
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
const groupedData = propsGroupedData || (props as any).groupedData;
// DynamicComponentRenderer에서는 _groupedData로 전달됨
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
const componentConfig = {
...config,
...component?.config,
@ -99,25 +100,99 @@ export function RepeatScreenModalComponent({
contentRowId: string;
} | null>(null);
// 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
useEffect(() => {
const handleTriggerSave = async (event: Event) => {
if (!(event instanceof CustomEvent)) return;
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
try {
setIsSaving(true);
// 기존 데이터 저장
if (cardMode === "withTable") {
await saveGroupedData();
} else {
await saveSimpleData();
}
// 외부 테이블 데이터 저장
await saveExternalTableData();
// 연동 저장 처리 (syncSaves)
await processSyncSaves();
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
// 저장 완료 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: true }
}));
// 성공 콜백 실행
if (event.detail?.onSuccess) {
event.detail.onSuccess();
}
} catch (error: any) {
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
// 저장 실패 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: false, error: error.message }
}));
// 실패 콜백 실행
if (event.detail?.onError) {
event.detail.onError(error);
}
} finally {
setIsSaving(false);
}
};
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
return () => {
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
};
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
const saveDataByTable: Record<string, any[]> = {};
for (const [key, rows] of Object.entries(externalTableData)) {
// key 형식: cardId-contentRowId
const keyParts = key.split("-");
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
// contentRow 찾기
const contentRow = contentRows.find((r) => key.includes(r.id));
if (!contentRow?.tableDataSource?.enabled) continue;
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
const card = groupedCardsData.find((c) => c._cardId === cardId);
const representativeData = card?._representativeData || {};
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
// dirty 행만 필터링 (삭제된 행 제외)
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
// 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
totalRows: rows.length,
dirtyRows: dirtyRows.length,
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
});
if (dirtyRows.length === 0) continue;
@ -126,8 +201,9 @@ export function RepeatScreenModalComponent({
.filter((col) => col.editable)
.map((col) => col.field);
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
.map((cond) => cond.sourceKey);
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
const joinConditions = contentRow.tableDataSource.joinConditions || [];
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
@ -145,6 +221,17 @@ export function RepeatScreenModalComponent({
}
}
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
for (const joinCond of joinConditions) {
const { sourceKey, targetKey } = joinCond;
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
saveData[sourceKey] = representativeData[targetKey];
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
}
}
// _isNew 플래그 유지
saveData._isNew = row._isNew;
saveData._targetTable = targetTable;
@ -590,18 +677,26 @@ export function RepeatScreenModalComponent({
if (!hasExternalAggregation) return;
// contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기
const tableRowWithExternalSource = contentRows.find(
// contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기
const tableRowsWithExternalSource = contentRows.filter(
(row) => row.type === "table" && row.tableDataSource?.enabled
);
if (!tableRowWithExternalSource) return;
if (tableRowsWithExternalSource.length === 0) return;
// 각 카드의 집계 재계산
const updatedCards = groupedCardsData.map((card) => {
const key = `${card._cardId}-${tableRowWithExternalSource.id}`;
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
const externalRowsByTableId: Record<string, any[]> = {};
const allExternalRows: any[] = [];
for (const tableRow of tableRowsWithExternalSource) {
const key = `${card._cardId}-${tableRow.id}`;
// 🆕 v3.7: 삭제된 행은 집계에서 제외
const externalRows = (extData[key] || []).filter((row) => !row._isDeleted);
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
externalRowsByTableId[tableRow.id] = rows;
allExternalRows.push(...rows);
}
// 집계 재계산
const newAggregations: Record<string, number> = {};
@ -616,7 +711,7 @@ export function RepeatScreenModalComponent({
if (isExternalTable) {
// 외부 테이블 집계
newAggregations[agg.resultField] = calculateColumnAggregation(
externalRows,
allExternalRows,
agg.sourceField || "",
agg.type || "sum"
);
@ -626,12 +721,28 @@ export function RepeatScreenModalComponent({
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
}
} else if (sourceType === "formula" && agg.formula) {
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
let filteredExternalRows: any[];
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
// 특정 테이블만 참조
filteredExternalRows = [];
for (const tableId of agg.externalTableRefs) {
if (externalRowsByTableId[tableId]) {
filteredExternalRows.push(...externalRowsByTableId[tableId]);
}
}
} else {
// 모든 외부 테이블 데이터 사용 (기존 동작)
filteredExternalRows = allExternalRows;
}
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
newAggregations[agg.resultField] = evaluateFormulaWithContext(
agg.formula,
card._representativeData,
card._rows,
externalRows,
filteredExternalRows,
newAggregations // 이전 집계 결과 참조
);
}
@ -654,8 +765,8 @@ export function RepeatScreenModalComponent({
});
};
// 🆕 v3.1: 외부 테이블 행 추가
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
const key = `${cardId}-${contentRowId}`;
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
@ -707,6 +818,41 @@ export function RepeatScreenModalComponent({
}
}
// 🆕 v3.13: 자동 채번 처리
const rowNumbering = contentRow.tableCrud?.rowNumbering;
console.log("[RepeatScreenModal] 채번 설정 확인:", {
tableCrud: contentRow.tableCrud,
rowNumbering,
enabled: rowNumbering?.enabled,
targetColumn: rowNumbering?.targetColumn,
numberingRuleId: rowNumbering?.numberingRuleId,
});
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
try {
console.log("[RepeatScreenModal] 자동 채번 시작:", {
targetColumn: rowNumbering.targetColumn,
numberingRuleId: rowNumbering.numberingRuleId,
});
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
console.log("[RepeatScreenModal] 자동 채번 완료:", {
column: rowNumbering.targetColumn,
generatedCode: response.data.generatedCode,
});
} else {
console.warn("[RepeatScreenModal] 채번 실패:", response);
}
} catch (error) {
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
}
}
console.log("[RepeatScreenModal] 새 행 추가:", {
cardId,
contentRowId,
@ -1009,26 +1155,70 @@ export function RepeatScreenModalComponent({
}
};
// 🆕 v3.1: 외부 테이블 행 삭제 실행 (소프트 삭제 - _isDeleted 플래그 설정)
const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
// 🆕 v3.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출)
const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => {
const key = `${cardId}-${contentRowId}`;
const rows = externalTableData[key] || [];
const targetRow = rows.find((row) => row._rowId === rowId);
// 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제
if (targetRow?._originalData?.id) {
try {
const contentRow = contentRows.find((r) => r.id === contentRowId);
const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable;
if (!targetTable) {
console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다.");
return;
}
console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`);
// 백엔드는 배열 형태의 데이터를 기대함
await apiClient.request({
method: "DELETE",
url: `/table-management/tables/${targetTable}/delete`,
data: [{ id: targetRow._originalData.id }],
});
console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`);
// 성공 시 UI에서 완전히 제거
setExternalTableData((prev) => {
const newData = {
...prev,
[key]: (prev[key] || []).map((row) =>
row._rowId === rowId
? { ...row, _isDeleted: true, _isDirty: true }
: row
),
[key]: prev[key].filter((row) => row._rowId !== rowId),
};
// 🆕 v3.5: 행 삭제 시 집계 재계산 (삭제된 행 제외)
// 행 삭제 시 집계 재계산
setTimeout(() => {
recalculateAggregationsWithExternalData(newData);
}, 0);
return newData;
});
} catch (error: any) {
console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message);
// 에러 시에도 다이얼로그 닫기
}
} else {
// 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거
console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`);
setExternalTableData((prev) => {
const newData = {
...prev,
[key]: prev[key].filter((row) => row._rowId !== rowId),
};
// 행 삭제 시 집계 재계산
setTimeout(() => {
recalculateAggregationsWithExternalData(newData);
}, 0);
return newData;
});
}
setDeleteConfirmOpen(false);
setPendingDeleteInfo(null);
};
@ -1323,8 +1513,13 @@ export function RepeatScreenModalComponent({
for (const fn of extAggFunctions) {
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
expression = expression.replace(regex, (match, fieldName) => {
if (!externalRows || externalRows.length === 0) return "0";
if (!externalRows || externalRows.length === 0) {
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
return "0";
}
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
const sum = values.reduce((a, b) => a + b, 0);
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
const baseFn = fn.replace("_EXT", "");
switch (baseFn) {
case "SUM":
@ -1525,6 +1720,9 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 저장
await saveExternalTableData();
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
await processSyncSaves();
alert("저장되었습니다.");
} catch (error: any) {
console.error("저장 실패:", error);
@ -1582,6 +1780,102 @@ export function RepeatScreenModalComponent({
});
};
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
const processSyncSaves = async () => {
const syncPromises: Promise<void>[] = [];
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
for (const contentRow of contentRows) {
if (contentRow.type !== "table") continue;
if (!contentRow.tableCrud?.syncSaves?.length) continue;
const sourceTable = contentRow.tableDataSource?.sourceTable;
if (!sourceTable) continue;
// 이 테이블 행의 모든 카드 데이터 수집
for (const card of groupedCardsData) {
const key = `${card._cardId}-${contentRow.id}`;
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
// 각 syncSave 설정 처리
for (const syncSave of contentRow.tableCrud.syncSaves) {
if (!syncSave.enabled) continue;
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
// 조인 키 값 수집 (중복 제거)
const joinKeyValues = new Set<string | number>();
for (const row of rows) {
const keyValue = row[syncSave.joinKey.sourceField];
if (keyValue !== undefined && keyValue !== null) {
joinKeyValues.add(keyValue);
}
}
// 각 조인 키별로 집계 계산 및 업데이트
for (const keyValue of joinKeyValues) {
// 해당 조인 키에 해당하는 행들만 필터링
const filteredRows = rows.filter(
(row) => row[syncSave.joinKey.sourceField] === keyValue
);
// 집계 계산
let aggregatedValue: number = 0;
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
switch (syncSave.aggregationType) {
case "sum":
aggregatedValue = values.reduce((a, b) => a + b, 0);
break;
case "count":
aggregatedValue = values.length;
break;
case "avg":
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "min":
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
break;
case "max":
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
break;
case "latest":
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
break;
}
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn}${syncSave.targetTable}.${syncSave.targetColumn}`, {
joinKey: keyValue,
aggregationType: syncSave.aggregationType,
values,
aggregatedValue,
});
// 대상 테이블 업데이트
syncPromises.push(
apiClient
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
[syncSave.targetColumn]: aggregatedValue,
})
.then(() => {
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
})
.catch((err) => {
console.error(`[SyncSave] 업데이트 실패:`, err);
throw err;
})
);
}
}
}
}
if (syncPromises.length > 0) {
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
await Promise.all(syncPromises);
console.log(`[SyncSave] 연동 저장 완료`);
}
};
// 🆕 v3.1: Footer 버튼 클릭 핸들러
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
switch (btn.action) {
@ -1928,27 +2222,10 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
<div className="border rounded-lg overflow-hidden">
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
{(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
<span>{contentRow.tableTitle || ""}</span>
<div className="flex items-center gap-2">
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
{contentRow.tableCrud?.allowSave && (
<Button
variant="default"
size="sm"
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
className="h-7 text-xs gap-1"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{contentRow.tableCrud?.saveButtonLabel || "저장"}
</Button>
)}
{/* 추가 버튼 */}
{contentRow.tableCrud?.allowCreate && (
<Button
@ -1968,7 +2245,8 @@ export function RepeatScreenModalComponent({
{contentRow.showTableHeader !== false && (
<TableHeader>
<TableRow className="bg-muted/50">
{(contentRow.tableColumns || []).map((col) => (
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableHead
key={col.id}
style={{ width: col.width }}
@ -1987,7 +2265,7 @@ export function RepeatScreenModalComponent({
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
<TableRow>
<TableCell
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
colSpan={(contentRow.tableColumns?.filter(col => !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
className="text-center py-8 text-muted-foreground"
>
.
@ -2003,7 +2281,8 @@ export function RepeatScreenModalComponent({
row._isDeleted && "bg-destructive/10 opacity-60"
)}
>
{(contentRow.tableColumns || []).map((col) => (
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn(

View File

@ -188,10 +188,6 @@ export interface TableCrudConfig {
allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용
// 🆕 v3.5: 테이블 영역 저장 버튼
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
// 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
@ -203,6 +199,54 @@ export interface TableCrudConfig {
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
syncSaves?: SyncSaveConfig[];
// 🆕 v3.13: 행 추가 시 자동 채번 설정
rowNumbering?: RowNumberingConfig;
}
/**
* 🆕 v3.13: 테이블
* "추가"
*
* :
* - (shipment_plan_no)
* - (invoice_no)
* - (work_order_no)
*
* 참고: 채번 "수정 가능"
*/
export interface RowNumberingConfig {
enabled: boolean; // 채번 사용 여부
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
}
/**
* 🆕 v3.12: 연동
*
*/
export interface SyncSaveConfig {
id: string; // 고유 ID
enabled: boolean; // 활성화 여부
// 소스 설정 (이 테이블에서)
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
// 대상 설정 (저장할 테이블)
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
}
/**
@ -285,10 +329,19 @@ export interface AggregationConfig {
// - 산술 연산: +, -, *, /, ()
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
// === 공통 ===
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
// === 🆕 v3.10: 숨김 설정 ===
hidden?: boolean; // 레이아웃에서 숨김 (연산에만 사용, 기본: false)
// === 🆕 v3.9: 저장 설정 ===
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
}
@ -337,6 +390,9 @@ export interface TableColumnConfig {
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
hidden?: boolean; // 숨김 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)

View File

@ -4,6 +4,7 @@ import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import type { DataProvidable } from "@/types/data-transfer";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
interface Option {
value: string;
@ -26,6 +27,7 @@ export interface SelectBasicComponentProps {
onDragEnd?: () => void;
value?: any; // 외부에서 전달받는 값
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
formData?: Record<string, any>; // 🆕 폼 데이터 (연쇄 드롭다운용)
[key: string]: any;
}
@ -50,6 +52,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onDragEnd,
value: externalValue, // 명시적으로 value prop 받기
menuObjid, // 🆕 메뉴 OBJID
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
...props
}) => {
// 🆕 읽기전용/비활성화 상태 확인
@ -151,6 +154,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
// 🆕 연쇄 드롭다운 설정 확인
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
// 자식 역할일 때만 부모 값 필요
const parentValue = cascadingRole === "child" && cascadingParentField && formData
? formData[cascadingParentField]
: undefined;
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드)
const {
options: cascadingOptions,
loading: isLoadingCascading,
} = useCascadingDropdown({
relationCode: cascadingRelationCode,
role: cascadingRole, // 부모/자식 역할 전달
parentValue: parentValue,
});
useEffect(() => {
if (webType === "category" && component.tableName && component.columnName) {
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
@ -301,12 +323,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 선택된 값에 따른 라벨 업데이트
useEffect(() => {
const getAllOptions = () => {
const getAllOptionsForLabel = () => {
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
}
const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions];
};
const options = getAllOptions();
const options = getAllOptionsForLabel();
const selectedOption = options.find((option) => option.value === selectedValue);
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
@ -327,7 +353,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel);
}
}, [selectedValue, codeOptions, config.options]);
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
// 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => {
@ -378,6 +404,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기
const getAllOptions = () => {
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
}
const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions];
};

View File

@ -1,15 +1,24 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Link2, ExternalLink } from "lucide-react";
import Link from "next/link";
import { SelectBasicConfig } from "./types";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
export interface SelectBasicConfigPanelProps {
config: SelectBasicConfig;
onChange: (config: Partial<SelectBasicConfig>) => void;
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
allComponents?: any[];
/** 현재 컴포넌트 정보 */
currentComponent?: any;
}
/**
@ -19,20 +28,134 @@ export interface SelectBasicConfigPanelProps {
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
config,
onChange,
allComponents = [],
currentComponent,
}) => {
// 연쇄 드롭다운 관련 상태
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// config 변경 시 상태 동기화
useEffect(() => {
setCascadingEnabled(!!config.cascadingRelationCode);
}, [config.cascadingRelationCode]);
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
const newConfig = { ...config, [key]: value };
onChange(newConfig);
};
// 연쇄 드롭다운 토글
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...config,
cascadingRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
};
onChange(newConfig);
} else {
loadRelationList();
}
};
// 🆕 같은 연쇄 관계의 부모 역할 컴포넌트 찾기
const findParentComponent = (relationCode: string) => {
console.log("🔍 findParentComponent 호출:", {
relationCode,
allComponentsLength: allComponents?.length,
currentComponentId: currentComponent?.id,
});
if (!allComponents || allComponents.length === 0) {
console.log("❌ allComponents가 비어있음");
return null;
}
// 모든 컴포넌트의 cascading 설정 확인
allComponents.forEach((comp: any) => {
const compConfig = comp.componentConfig || {};
if (compConfig.cascadingRelationCode) {
console.log("📦 컴포넌트 cascading 설정:", {
id: comp.id,
columnName: comp.columnName,
cascadingRelationCode: compConfig.cascadingRelationCode,
cascadingRole: compConfig.cascadingRole,
});
}
});
const found = allComponents.find((comp: any) => {
const compConfig = comp.componentConfig || {};
return (
comp.id !== currentComponent?.id && // 자기 자신 제외
compConfig.cascadingRelationCode === relationCode &&
compConfig.cascadingRole === "parent"
);
});
console.log("🔍 찾은 부모 컴포넌트:", found);
return found;
};
// 역할 변경 시 부모 필드 자동 감지
const handleRoleChange = (role: "parent" | "child") => {
let parentField = config.cascadingParentField;
// 자식 역할 선택 시 부모 필드 자동 감지
if (role === "child" && config.cascadingRelationCode) {
const parentComp = findParentComponent(config.cascadingRelationCode);
if (parentComp) {
parentField = parentComp.columnName;
console.log("🔗 부모 필드 자동 감지:", parentField);
}
}
const newConfig = {
...config,
cascadingRole: role,
// 부모 역할일 때는 부모 필드 불필요, 자식일 때는 자동 감지된 값 또는 기존 값
cascadingParentField: role === "parent" ? undefined : parentField,
};
onChange(newConfig);
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === config.cascadingRelationCode);
return (
<div className="space-y-4">
<div className="text-sm font-medium">
select-basic
</div>
{/* select 관련 설정 */}
{/* select 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
@ -78,6 +201,179 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("multiple", checked)}
/>
</div>
{/* 연쇄 드롭다운 설정 */}
<div className="border-t pt-4 mt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<Label className="text-sm font-medium"> </Label>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
.
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.cascadingRelationCode || ""}
onValueChange={(value) => handleChange("cascadingRelationCode", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{config.cascadingRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={config.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={config.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{config.cascadingRole === "parent"
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
: config.cascadingRole === "child"
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{config.cascadingRelationCode && config.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{(() => {
const parentComp = findParentComponent(config.cascadingRelationCode);
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
return (
<>
<div className="flex gap-2 items-center">
<Input
value={config.cascadingParentField || ""}
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
placeholder="예: warehouse_code"
className="text-xs flex-1"
/>
{parentComp && !isAutoDetected && (
<Button
type="button"
size="sm"
variant="outline"
className="text-xs shrink-0"
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
>
</Button>
)}
</div>
{isAutoDetected ? (
<p className="text-xs text-green-600">
: {parentComp.label || parentComp.columnName}
</p>
) : parentComp ? (
<p className="text-xs text-amber-600">
: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
</p>
) : (
<p className="text-muted-foreground text-xs">
. .
</p>
)}
</>
);
})()}
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && config.cascadingRole && (
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
{config.cascadingRole === "parent" ? (
<>
<div className="font-medium text-blue-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_value_column}</span>
</div>
</>
) : (
<>
<div className="font-medium text-green-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_filter_column}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_value_column}</span>
</div>
</>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -14,6 +14,14 @@ export interface SelectBasicConfig extends ComponentConfig {
// 코드 관련 설정
codeCategory?: string;
// 🆕 연쇄 드롭다운 설정
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
cascadingRelationCode?: string;
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
cascadingRole?: "parent" | "child";
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
cascadingParentField?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;

Some files were not shown because too many files have changed in this diff Show More