feature/v2-renewal #400

Merged
kjs merged 11 commits from feature/v2-renewal into main 2026-03-04 23:03:04 +09:00
97 changed files with 4557 additions and 529 deletions
Showing only changes of commit d7ef26d679 - Show all commits

View File

@ -1120,8 +1120,8 @@ export async function saveMenu(
`INSERT INTO menu_info ( `INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, regdate, status, seq, menu_url, menu_desc, writer, regdate, status,
system_name, company_code, lang_key, lang_key_desc, screen_code system_name, company_code, lang_key, lang_key_desc, screen_code, menu_icon
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *`, RETURNING *`,
[ [
objid, objid,
@ -1140,6 +1140,7 @@ export async function saveMenu(
menuData.langKey || null, menuData.langKey || null,
menuData.langKeyDesc || null, menuData.langKeyDesc || null,
screenCode, screenCode,
menuData.menuIcon || null,
] ]
); );
@ -1323,8 +1324,9 @@ export async function updateMenu(
company_code = $10, company_code = $10,
lang_key = $11, lang_key = $11,
lang_key_desc = $12, lang_key_desc = $12,
screen_code = $13 screen_code = $13,
WHERE objid = $14 menu_icon = $14
WHERE objid = $15
RETURNING *`, RETURNING *`,
[ [
menuData.menuType ? Number(menuData.menuType) : null, menuData.menuType ? Number(menuData.menuType) : null,
@ -1340,6 +1342,7 @@ export async function updateMenu(
menuData.langKey || null, menuData.langKey || null,
menuData.langKeyDesc || null, menuData.langKeyDesc || null,
screenCode, screenCode,
menuData.menuIcon || null,
Number(menuId), Number(menuId),
] ]
); );

View File

@ -9,6 +9,7 @@ import { FlowStepService } from "../services/flowStepService";
import { FlowConnectionService } from "../services/flowConnectionService"; import { FlowConnectionService } from "../services/flowConnectionService";
import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowExecutionService } from "../services/flowExecutionService";
import { FlowDataMoveService } from "../services/flowDataMoveService"; import { FlowDataMoveService } from "../services/flowDataMoveService";
import { FlowProcedureService } from "../services/flowProcedureService";
export class FlowController { export class FlowController {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -16,6 +17,7 @@ export class FlowController {
private flowConnectionService: FlowConnectionService; private flowConnectionService: FlowConnectionService;
private flowExecutionService: FlowExecutionService; private flowExecutionService: FlowExecutionService;
private flowDataMoveService: FlowDataMoveService; private flowDataMoveService: FlowDataMoveService;
private flowProcedureService: FlowProcedureService;
constructor() { constructor() {
this.flowDefinitionService = new FlowDefinitionService(); this.flowDefinitionService = new FlowDefinitionService();
@ -23,6 +25,7 @@ export class FlowController {
this.flowConnectionService = new FlowConnectionService(); this.flowConnectionService = new FlowConnectionService();
this.flowExecutionService = new FlowExecutionService(); this.flowExecutionService = new FlowExecutionService();
this.flowDataMoveService = new FlowDataMoveService(); this.flowDataMoveService = new FlowDataMoveService();
this.flowProcedureService = new FlowProcedureService();
} }
// ==================== 플로우 정의 ==================== // ==================== 플로우 정의 ====================
@ -144,8 +147,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const userCompanyCode = (req as any).user?.companyCode;
const definition = await this.flowDefinitionService.findById(flowId); const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode);
if (!definition) { if (!definition) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
@ -182,12 +186,13 @@ export class FlowController {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const { name, description, isActive } = req.body; const { name, description, isActive } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
const flowDef = await this.flowDefinitionService.update(flowId, { const flowDef = await this.flowDefinitionService.update(flowId, {
name, name,
description, description,
isActive, isActive,
}); }, userCompanyCode);
if (!flowDef) { if (!flowDef) {
res.status(404).json({ res.status(404).json({
@ -217,8 +222,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const userCompanyCode = (req as any).user?.companyCode;
const success = await this.flowDefinitionService.delete(flowId); const success = await this.flowDefinitionService.delete(flowId, userCompanyCode);
if (!success) { if (!success) {
res.status(404).json({ res.status(404).json({
@ -275,6 +281,7 @@ export class FlowController {
try { try {
const { flowId } = req.params; const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId); const flowDefinitionId = parseInt(flowId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -293,6 +300,16 @@ export class FlowController {
return; return;
} }
// 플로우 소유권 검증
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(404).json({
success: false,
message: "Flow definition not found or access denied",
});
return;
}
const step = await this.flowStepService.create({ const step = await this.flowStepService.create({
flowDefinitionId, flowDefinitionId,
stepName, stepName,
@ -324,6 +341,7 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); const id = parseInt(stepId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -342,6 +360,19 @@ export class FlowController {
displayConfig, displayConfig,
} = req.body; } = req.body;
// 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인
const existingStep = await this.flowStepService.findById(id);
if (existingStep) {
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const step = await this.flowStepService.update(id, { const step = await this.flowStepService.update(id, {
stepName, stepName,
stepOrder, stepOrder,
@ -388,6 +419,20 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); const id = parseInt(stepId);
const userCompanyCode = (req as any).user?.companyCode;
// 스텝 소유권 검증
const existingStep = await this.flowStepService.findById(id);
if (existingStep) {
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const success = await this.flowStepService.delete(id); const success = await this.flowStepService.delete(id);
@ -446,6 +491,7 @@ export class FlowController {
createConnection = async (req: Request, res: Response): Promise<void> => { createConnection = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowDefinitionId, fromStepId, toStepId, label } = req.body; const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
if (!flowDefinitionId || !fromStepId || !toStepId) { if (!flowDefinitionId || !fromStepId || !toStepId) {
res.status(400).json({ res.status(400).json({
@ -455,6 +501,28 @@ export class FlowController {
return; return;
} }
// 플로우 소유권 검증
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(404).json({
success: false,
message: "Flow definition not found or access denied",
});
return;
}
// fromStepId, toStepId가 해당 flow에 속하는지 검증
const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId);
if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId ||
!toStep || toStep.flowDefinitionId !== flowDefinitionId) {
res.status(400).json({
success: false,
message: "fromStepId and toStepId must belong to the specified flow",
});
return;
}
const connection = await this.flowConnectionService.create({ const connection = await this.flowConnectionService.create({
flowDefinitionId, flowDefinitionId,
fromStepId, fromStepId,
@ -482,6 +550,20 @@ export class FlowController {
try { try {
const { connectionId } = req.params; const { connectionId } = req.params;
const id = parseInt(connectionId); const id = parseInt(connectionId);
const userCompanyCode = (req as any).user?.companyCode;
// 연결 소유권 검증
const existingConn = await this.flowConnectionService.findById(id);
if (existingConn) {
const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const success = await this.flowConnectionService.delete(id); const success = await this.flowConnectionService.delete(id);
@ -670,23 +752,24 @@ export class FlowController {
*/ */
moveData = async (req: Request, res: Response): Promise<void> => { moveData = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowId, recordId, toStepId, note } = req.body; const { flowId, fromStepId, recordId, toStepId, note } = req.body;
const userId = (req as any).user?.userId || "system"; const userId = (req as any).user?.userId || "system";
if (!flowId || !recordId || !toStepId) { if (!flowId || !fromStepId || !recordId || !toStepId) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "flowId, recordId, and toStepId are required", message: "flowId, fromStepId, recordId, and toStepId are required",
}); });
return; return;
} }
await this.flowDataMoveService.moveDataToStep( await this.flowDataMoveService.moveDataToStep(
flowId, flowId,
recordId, fromStepId,
toStepId, toStepId,
recordId,
userId, userId,
note note ? { note } : undefined
); );
res.json({ res.json({
@ -856,4 +939,94 @@ export class FlowController {
}); });
} }
}; };
// ==================== 프로시저/함수 ====================
/**
* /
*/
listProcedures = async (req: Request, res: Response): Promise<void> => {
try {
const dbSource = (req.query.dbSource as string) || "internal";
const connectionId = req.query.connectionId
? parseInt(req.query.connectionId as string)
: undefined;
const schema = req.query.schema as string | undefined;
if (dbSource !== "internal" && dbSource !== "external") {
res.status(400).json({
success: false,
message: "dbSource는 internal 또는 external이어야 합니다",
});
return;
}
if (dbSource === "external" && !connectionId) {
res.status(400).json({
success: false,
message: "외부 DB 조회 시 connectionId가 필요합니다",
});
return;
}
const procedures = await this.flowProcedureService.listProcedures(
dbSource,
connectionId,
schema
);
res.json({ success: true, data: procedures });
} catch (error: any) {
console.error("프로시저 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "프로시저 목록 조회에 실패했습니다",
});
}
};
/**
* /
*/
getProcedureParameters = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const dbSource = (req.query.dbSource as string) || "internal";
const connectionId = req.query.connectionId
? parseInt(req.query.connectionId as string)
: undefined;
const schema = req.query.schema as string | undefined;
if (!name) {
res.status(400).json({
success: false,
message: "프로시저 이름이 필요합니다",
});
return;
}
if (dbSource !== "internal" && dbSource !== "external") {
res.status(400).json({
success: false,
message: "dbSource는 internal 또는 external이어야 합니다",
});
return;
}
const parameters = await this.flowProcedureService.getProcedureParameters(
name,
dbSource as "internal" | "external",
connectionId,
schema
);
res.json({ success: true, data: parameters });
} catch (error: any) {
console.error("프로시저 파라미터 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "프로시저 파라미터 조회에 실패했습니다",
});
}
};
} }

View File

@ -50,4 +50,8 @@ router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs);
// ==================== 프로시저/함수 ====================
router.get("/procedures", flowController.listProcedures);
router.get("/procedures/:name/parameters", flowController.getProcedureParameters);
export default router; export default router;

View File

@ -227,7 +227,8 @@ export class AdminService {
PATH, PATH,
CYCLE, CYCLE,
TRANSLATED_NAME, TRANSLATED_NAME,
TRANSLATED_DESC TRANSLATED_DESC,
MENU_ICON
) AS ( ) AS (
SELECT SELECT
1 AS LEVEL, 1 AS LEVEL,
@ -282,7 +283,8 @@ export class AdminService {
AND MLT.lang_code = $1 AND MLT.lang_code = $1
LIMIT 1), LIMIT 1),
MENU.MENU_DESC MENU.MENU_DESC
) ),
MENU.MENU_ICON
FROM MENU_INFO MENU FROM MENU_INFO MENU
WHERE ${menuTypeCondition} WHERE ${menuTypeCondition}
AND ${statusCondition} AND ${statusCondition}
@ -348,7 +350,8 @@ export class AdminService {
AND MLT.lang_code = $1 AND MLT.lang_code = $1
LIMIT 1), LIMIT 1),
MENU_SUB.MENU_DESC MENU_SUB.MENU_DESC
) ),
MENU_SUB.MENU_ICON
FROM MENU_INFO MENU_SUB FROM MENU_INFO MENU_SUB
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH)
@ -374,6 +377,7 @@ export class AdminService {
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
A.TRANSLATED_NAME, A.TRANSLATED_NAME,
A.TRANSLATED_DESC, A.TRANSLATED_DESC,
A.MENU_ICON,
CASE UPPER(A.STATUS) CASE UPPER(A.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@ -514,7 +518,8 @@ export class AdminService {
LANG_KEY, LANG_KEY,
LANG_KEY_DESC, LANG_KEY_DESC,
PATH, PATH,
CYCLE CYCLE,
MENU_ICON
) AS ( ) AS (
SELECT SELECT
1 AS LEVEL, 1 AS LEVEL,
@ -532,7 +537,8 @@ export class AdminService {
LANG_KEY, LANG_KEY,
LANG_KEY_DESC, LANG_KEY_DESC,
ARRAY [MENU.OBJID], ARRAY [MENU.OBJID],
FALSE FALSE,
MENU.MENU_ICON
FROM MENU_INFO MENU FROM MENU_INFO MENU
WHERE PARENT_OBJ_ID = 0 WHERE PARENT_OBJ_ID = 0
AND MENU_TYPE = 1 AND MENU_TYPE = 1
@ -558,7 +564,8 @@ export class AdminService {
MENU_SUB.LANG_KEY, MENU_SUB.LANG_KEY,
MENU_SUB.LANG_KEY_DESC, MENU_SUB.LANG_KEY_DESC,
PATH || MENU_SUB.SEQ::numeric, PATH || MENU_SUB.SEQ::numeric,
MENU_SUB.OBJID = ANY(PATH) MENU_SUB.OBJID = ANY(PATH),
MENU_SUB.MENU_ICON
FROM MENU_INFO MENU_SUB FROM MENU_INFO MENU_SUB
JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID
WHERE MENU_SUB.STATUS = 'active' WHERE MENU_SUB.STATUS = 'active'
@ -584,10 +591,9 @@ export class AdminService {
A.COMPANY_CODE, A.COMPANY_CODE,
A.LANG_KEY, A.LANG_KEY,
A.LANG_KEY_DESC, A.LANG_KEY_DESC,
A.MENU_ICON,
COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME,
-- (우선순위: 번역 > )
COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME, COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME,
-- (우선순위: 번역 > )
COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC, COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC,
CASE UPPER(A.STATUS) CASE UPPER(A.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'

View File

@ -210,19 +210,62 @@ export class DynamicFormService {
} }
} }
/**
* VIEW인 (base) ,
*/
async resolveBaseTable(tableName: string): Promise<string> {
try {
const result = await query<{ table_type: string }>(
`SELECT table_type FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (result.length === 0 || result[0].table_type !== 'VIEW') {
return tableName;
}
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
const viewDef = await query<{ view_definition: string }>(
`SELECT view_definition FROM information_schema.views
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (viewDef.length > 0) {
const definition = viewDef[0].view_definition;
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
if (fromMatch) {
const baseTable = fromMatch[1];
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
return baseTable;
}
}
return tableName;
} catch (error) {
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
return tableName;
}
}
/** /**
* ( ) * ( )
*/ */
async saveFormData( async saveFormData(
screenId: number, screenId: number,
tableName: string, tableNameInput: string,
data: Record<string, any>, data: Record<string, any>,
ipAddress?: string ipAddress?: string
): Promise<FormDataResult> { ): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
screenId, screenId,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data, data,
}); });
@ -813,14 +856,17 @@ export class DynamicFormService {
*/ */
async updateFormDataPartial( async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원 id: string | number, // 🔧 UUID 문자열도 지원
tableName: string, tableNameInput: string,
originalData: Record<string, any>, originalData: Record<string, any>,
newData: Record<string, any> newData: Record<string, any>
): Promise<PartialUpdateResult> { ): Promise<PartialUpdateResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("🔄 서비스: 부분 업데이트 시작:", { console.log("🔄 서비스: 부분 업데이트 시작:", {
id, id,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
originalData, originalData,
newData, newData,
}); });
@ -1008,13 +1054,16 @@ export class DynamicFormService {
*/ */
async updateFormData( async updateFormData(
id: string | number, id: string | number,
tableName: string, tableNameInput: string,
data: Record<string, any> data: Record<string, any>
): Promise<FormDataResult> { ): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
id, id,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data, data,
}); });
@ -1215,9 +1264,13 @@ export class DynamicFormService {
screenId?: number screenId?: number
): Promise<void> { ): Promise<void> {
try { try {
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
const actualTable = await this.resolveBaseTable(tableName);
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
id, id,
tableName, tableName: actualTable,
originalTable: tableName !== actualTable ? tableName : undefined,
}); });
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
@ -1235,15 +1288,15 @@ export class DynamicFormService {
`; `;
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName); console.log("🔍 테이블명:", actualTable);
const primaryKeyResult = await query<{ const primaryKeyResult = await query<{
column_name: string; column_name: string;
data_type: string; data_type: string;
}>(primaryKeyQuery, [tableName]); }>(primaryKeyQuery, [actualTable]);
if (!primaryKeyResult || primaryKeyResult.length === 0) { if (!primaryKeyResult || primaryKeyResult.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
} }
const primaryKeyInfo = primaryKeyResult[0]; const primaryKeyInfo = primaryKeyResult[0];
@ -1275,7 +1328,7 @@ export class DynamicFormService {
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
const deleteQuery = ` const deleteQuery = `
DELETE FROM ${tableName} DELETE FROM ${actualTable}
WHERE ${primaryKeyColumn} = $1${typeCastSuffix} WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
RETURNING * RETURNING *
`; `;
@ -1295,7 +1348,7 @@ export class DynamicFormService {
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것 // 삭제된 행이 없으면 레코드를 찾을 수 없는 것
if (!result || !Array.isArray(result) || result.length === 0) { if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
} }
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

View File

@ -132,14 +132,23 @@ export class FlowConditionParser {
/** /**
* SQL * SQL
*/ */
private static sanitizeColumnName(columnName: string): string { static sanitizeColumnName(columnName: string): string {
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`); throw new Error(`Invalid column name: ${columnName}`);
} }
return columnName; return columnName;
} }
/**
* SQL
*/
static sanitizeTableName(tableName: string): string {
if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
return tableName;
}
/** /**
* *
*/ */

View File

@ -25,16 +25,21 @@ import {
buildInsertQuery, buildInsertQuery,
buildSelectQuery, buildSelectQuery,
} from "./dbQueryBuilder"; } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
import { FlowProcedureService } from "./flowProcedureService";
import { FlowProcedureConfig } from "../types/flow";
export class FlowDataMoveService { export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService; private flowStepService: FlowStepService;
private externalDbIntegrationService: FlowExternalDbIntegrationService; private externalDbIntegrationService: FlowExternalDbIntegrationService;
private flowProcedureService: FlowProcedureService;
constructor() { constructor() {
this.flowDefinitionService = new FlowDefinitionService(); this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService(); this.flowStepService = new FlowStepService();
this.externalDbIntegrationService = new FlowExternalDbIntegrationService(); this.externalDbIntegrationService = new FlowExternalDbIntegrationService();
this.flowProcedureService = new FlowProcedureService();
} }
/** /**
@ -89,6 +94,64 @@ export class FlowDataMoveService {
let sourceTable = fromStep.tableName; let sourceTable = fromStep.tableName;
let targetTable = toStep.tableName || fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName;
// 1.5. 프로시저 호출 (스텝 이동 전 실행, 실패 시 전체 롤백)
if (
toStep.integrationType === "procedure" &&
toStep.integrationConfig &&
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
) {
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
// 레코드 데이터 조회 (파라미터 매핑용)
let recordData: Record<string, any> = {};
try {
const recordTable = FlowConditionParser.sanitizeTableName(
sourceTable || flowDefinition.tableName
);
const recordResult = await client.query(
`SELECT * FROM ${recordTable} WHERE id = $1 LIMIT 1`,
[dataId]
);
if (recordResult.rows && recordResult.rows.length > 0) {
recordData = recordResult.rows[0];
}
} catch (err: any) {
console.warn("프로시저 파라미터용 레코드 조회 실패:", err.message);
}
console.log(`프로시저 호출 시작: ${procConfig.procedureName}`, {
flowId,
fromStepId,
toStepId,
dataId,
dbSource: procConfig.dbSource,
});
const procResult = await this.flowProcedureService.executeProcedure(
procConfig,
recordData,
procConfig.dbSource === "internal" ? client : undefined
);
console.log(`프로시저 호출 완료: ${procConfig.procedureName}`, {
success: procResult.success,
});
// 프로시저 실행 로그 기록
await this.logIntegration(
flowId,
toStep.id,
dataId,
"procedure",
procConfig.connectionId,
procConfig,
procResult.result,
"success",
undefined,
0,
userId
);
}
// 2. 이동 방식에 따라 처리 // 2. 이동 방식에 따라 처리
switch (toStep.moveType || "status") { switch (toStep.moveType || "status") {
case "status": case "status":
@ -236,18 +299,19 @@ export class FlowDataMoveService {
); );
} }
const statusColumn = toStep.statusColumn; const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn);
const tableName = fromStep.tableName; const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName);
// 추가 필드 업데이트 준비 // 추가 필드 업데이트 준비
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
const values: any[] = [dataId, toStep.statusValue]; const values: any[] = [dataId, toStep.statusValue];
let paramIndex = 3; let paramIndex = 3;
// 추가 데이터가 있으면 함께 업데이트 // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함)
if (additionalData) { if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) { for (const [key, value] of Object.entries(additionalData)) {
updates.push(`${key} = $${paramIndex}`); const safeKey = FlowConditionParser.sanitizeColumnName(key);
updates.push(`${safeKey} = $${paramIndex}`);
values.push(value); values.push(value);
paramIndex++; paramIndex++;
} }
@ -276,33 +340,38 @@ export class FlowDataMoveService {
dataId: any, dataId: any,
additionalData?: Record<string, any> additionalData?: Record<string, any>
): Promise<any> { ): Promise<any> {
const sourceTable = fromStep.tableName; const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName);
const targetTable = toStep.targetTable || toStep.tableName; const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName);
const fieldMappings = toStep.fieldMappings || {}; const fieldMappings = toStep.fieldMappings || {};
// 1. 소스 데이터 조회 // 1. 소스 데이터 조회
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
const sourceResult = await client.query(selectQuery, [dataId]); const sourceResult = await client.query(selectQuery, [dataId]);
if (sourceResult.length === 0) { if (sourceResult.rows.length === 0) {
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
} }
const sourceData = sourceResult[0]; const sourceData = sourceResult.rows[0];
// 2. 필드 매핑 적용 // 2. 필드 매핑 적용
const mappedData: Record<string, any> = {}; const mappedData: Record<string, any> = {};
// 매핑 정의가 있으면 적용 // 매핑 정의가 있으면 적용 (컬럼명 검증)
for (const [sourceField, targetField] of Object.entries(fieldMappings)) { for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
FlowConditionParser.sanitizeColumnName(sourceField);
FlowConditionParser.sanitizeColumnName(targetField as string);
if (sourceData[sourceField] !== undefined) { if (sourceData[sourceField] !== undefined) {
mappedData[targetField as string] = sourceData[sourceField]; mappedData[targetField as string] = sourceData[sourceField];
} }
} }
// 추가 데이터 병합 // 추가 데이터 병합 (키 검증)
if (additionalData) { if (additionalData) {
Object.assign(mappedData, additionalData); for (const [key, value] of Object.entries(additionalData)) {
const safeKey = FlowConditionParser.sanitizeColumnName(key);
mappedData[safeKey] = value;
}
} }
// 3. 타겟 테이블에 데이터 삽입 // 3. 타겟 테이블에 데이터 삽입
@ -321,7 +390,7 @@ export class FlowDataMoveService {
`; `;
const insertResult = await client.query(insertQuery, values); const insertResult = await client.query(insertQuery, values);
return insertResult[0].id; return insertResult.rows[0].id;
} }
/** /**
@ -349,12 +418,12 @@ export class FlowDataMoveService {
]); ]);
const stepDataMap: Record<string, string> = const stepDataMap: Record<string, string> =
mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {};
// 새 단계 데이터 추가 // 새 단계 데이터 추가
stepDataMap[String(currentStepId)] = String(targetDataId); stepDataMap[String(currentStepId)] = String(targetDataId);
if (mappingResult.length > 0) { if (mappingResult.rows.length > 0) {
// 기존 매핑 업데이트 // 기존 매핑 업데이트
const updateQuery = ` const updateQuery = `
UPDATE flow_data_mapping UPDATE flow_data_mapping
@ -366,7 +435,7 @@ export class FlowDataMoveService {
await client.query(updateQuery, [ await client.query(updateQuery, [
currentStepId, currentStepId,
JSON.stringify(stepDataMap), JSON.stringify(stepDataMap),
mappingResult[0].id, mappingResult.rows[0].id,
]); ]);
} else { } else {
// 새 매핑 생성 // 새 매핑 생성
@ -596,18 +665,19 @@ export class FlowDataMoveService {
} }
break; break;
case "procedure":
// 프로시저는 데이터 이동 전에 이미 실행됨 (step 1.5)
break;
case "rest_api": case "rest_api":
// REST API 연동 (추후 구현)
console.warn("REST API 연동은 아직 구현되지 않았습니다"); console.warn("REST API 연동은 아직 구현되지 않았습니다");
break; break;
case "webhook": case "webhook":
// Webhook 연동 (추후 구현)
console.warn("Webhook 연동은 아직 구현되지 않았습니다"); console.warn("Webhook 연동은 아직 구현되지 않았습니다");
break; break;
case "hybrid": case "hybrid":
// 복합 연동 (추후 구현)
console.warn("복합 연동은 아직 구현되지 않았습니다"); console.warn("복합 연동은 아직 구현되지 않았습니다");
break; break;
@ -709,6 +779,40 @@ export class FlowDataMoveService {
let sourceTable = fromStep.tableName; let sourceTable = fromStep.tableName;
let targetTable = toStep.tableName || fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName;
// 1.5. 프로시저 호출 (외부 DB 경로 - 스텝 이동 전)
if (
toStep.integrationType === "procedure" &&
toStep.integrationConfig &&
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
) {
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
let recordData: Record<string, any> = {};
try {
const recordTable = FlowConditionParser.sanitizeTableName(
sourceTable || ""
);
if (recordTable) {
const placeholder = getPlaceholder(dbType, 1);
const recordResult = await externalClient.query(
`SELECT * FROM ${recordTable} WHERE id = ${placeholder}`,
[dataId]
);
const rows = recordResult.rows || recordResult;
if (Array.isArray(rows) && rows.length > 0) {
recordData = rows[0];
}
}
} catch (err: any) {
console.warn("프로시저 파라미터용 레코드 조회 실패 (외부):", err.message);
}
await this.flowProcedureService.executeProcedure(
procConfig,
recordData,
procConfig.dbSource === "external" ? undefined : undefined
);
}
// 2. 이동 방식에 따라 처리 // 2. 이동 방식에 따라 처리
switch (toStep.moveType || "status") { switch (toStep.moveType || "status") {
case "status": case "status":

View File

@ -19,7 +19,8 @@ export class FlowDefinitionService {
userId: string, userId: string,
userCompanyCode?: string userCompanyCode?: string
): Promise<FlowDefinition> { ): Promise<FlowDefinition> {
const companyCode = request.companyCode || userCompanyCode || "*"; // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용
const companyCode = userCompanyCode || "*";
console.log("🔥 flowDefinitionService.create called with:", { console.log("🔥 flowDefinitionService.create called with:", {
name: request.name, name: request.name,
@ -118,10 +119,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async findById(id: number): Promise<FlowDefinition | null> { async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1"; let query: string;
const result = await db.query(query, [id]); let params: any[];
if (companyCode && companyCode !== "*") {
query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2";
params = [id, companyCode];
} else {
query = "SELECT * FROM flow_definition WHERE id = $1";
params = [id];
}
const result = await db.query(query, params);
if (result.length === 0) { if (result.length === 0) {
return null; return null;
@ -132,10 +144,12 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async update( async update(
id: number, id: number,
request: UpdateFlowDefinitionRequest request: UpdateFlowDefinitionRequest,
companyCode?: string
): Promise<FlowDefinition | null> { ): Promise<FlowDefinition | null> {
const fields: string[] = []; const fields: string[] = [];
const params: any[] = []; const params: any[] = [];
@ -160,18 +174,27 @@ export class FlowDefinitionService {
} }
if (fields.length === 0) { if (fields.length === 0) {
return this.findById(id); return this.findById(id, companyCode);
} }
fields.push(`updated_at = NOW()`); fields.push(`updated_at = NOW()`);
let whereClause = `WHERE id = $${paramIndex}`;
params.push(id);
paramIndex++;
if (companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
const query = ` const query = `
UPDATE flow_definition UPDATE flow_definition
SET ${fields.join(", ")} SET ${fields.join(", ")}
WHERE id = $${paramIndex} ${whereClause}
RETURNING * RETURNING *
`; `;
params.push(id);
const result = await db.query(query, params); const result = await db.query(query, params);
@ -184,10 +207,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async delete(id: number): Promise<boolean> { async delete(id: number, companyCode?: string): Promise<boolean> {
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; let query: string;
const result = await db.query(query, [id]); let params: any[];
if (companyCode && companyCode !== "*") {
query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id";
params = [id, companyCode];
} else {
query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
params = [id];
}
const result = await db.query(query, params);
return result.length > 0; return result.length > 0;
} }

View File

@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser"; import { FlowConditionParser } from "./flowConditionParser";
import { executeExternalQuery } from "./externalDbHelper"; import { executeExternalQuery } from "./externalDbHelper";
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowExecutionService { export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -42,7 +43,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -96,7 +97,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -267,11 +268,12 @@ export class FlowExecutionService {
throw new Error(`Flow step not found: ${stepId}`); throw new Error(`Flow step not found: ${stepId}`);
} }
// 3. 테이블명 결정 // 3. 테이블명 결정 (SQL 인젝션 방지)
const tableName = step.tableName || flowDef.tableName; const rawTableName = step.tableName || flowDef.tableName;
if (!tableName) { if (!rawTableName) {
throw new Error("Table name not found"); throw new Error("Table name not found");
} }
const tableName = FlowConditionParser.sanitizeTableName(rawTableName);
// 4. Primary Key 컬럼 결정 (기본값: id) // 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id"; const primaryKeyColumn = flowDef.primaryKey || "id";
@ -280,8 +282,10 @@ export class FlowExecutionService {
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
); );
// 5. SET 절 생성 // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지)
const updateColumns = Object.keys(updateData); const updateColumns = Object.keys(updateData).map((col) =>
FlowConditionParser.sanitizeColumnName(col)
);
if (updateColumns.length === 0) { if (updateColumns.length === 0) {
throw new Error("No columns to update"); throw new Error("No columns to update");
} }

View File

@ -0,0 +1,429 @@
/**
*
* / DB의 / , ,
*/
import db from "../database/db";
import {
getExternalPool,
executeExternalQuery,
} from "./externalDbHelper";
import { getPlaceholder } from "./dbQueryBuilder";
import {
FlowProcedureConfig,
FlowProcedureParam,
ProcedureListItem,
ProcedureParameterInfo,
} from "../types/flow";
export class FlowProcedureService {
/**
* /
* information_schema.routines에서 /
*/
async listProcedures(
dbSource: "internal" | "external",
connectionId?: number,
schema?: string
): Promise<ProcedureListItem[]> {
if (dbSource === "external" && connectionId) {
return this.listExternalProcedures(connectionId, schema);
}
return this.listInternalProcedures(schema);
}
private async listInternalProcedures(
schema?: string
): Promise<ProcedureListItem[]> {
const targetSchema = schema || "public";
// 트리거 함수(data_type='trigger')는 직접 호출 대상이 아니므로 제외
const query = `
SELECT
routine_name AS name,
routine_schema AS schema,
routine_type AS type,
data_type AS return_type
FROM information_schema.routines
WHERE routine_schema = $1
AND routine_type IN ('PROCEDURE', 'FUNCTION')
AND data_type != 'trigger'
ORDER BY routine_type, routine_name
`;
const rows = await db.query(query, [targetSchema]);
return rows.map((r: any) => ({
name: r.name,
schema: r.schema,
type: r.type as "PROCEDURE" | "FUNCTION",
returnType: r.return_type || undefined,
}));
}
private async listExternalProcedures(
connectionId: number,
schema?: string
): Promise<ProcedureListItem[]> {
const poolInfo = await getExternalPool(connectionId);
const dbType = poolInfo.dbType.toLowerCase();
let query: string;
let params: any[];
switch (dbType) {
case "postgresql": {
const targetSchema = schema || "public";
query = `
SELECT
routine_name AS name,
routine_schema AS schema,
routine_type AS type,
data_type AS return_type
FROM information_schema.routines
WHERE routine_schema = $1
AND routine_type IN ('PROCEDURE', 'FUNCTION')
AND data_type != 'trigger'
ORDER BY routine_type, routine_name
`;
params = [targetSchema];
break;
}
case "mysql":
case "mariadb": {
query = `
SELECT
ROUTINE_NAME AS name,
ROUTINE_SCHEMA AS \`schema\`,
ROUTINE_TYPE AS type,
DATA_TYPE AS return_type
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = DATABASE()
AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
`;
params = [];
break;
}
case "mssql": {
query = `
SELECT
ROUTINE_NAME AS name,
ROUTINE_SCHEMA AS [schema],
ROUTINE_TYPE AS type,
DATA_TYPE AS return_type
FROM INFORMATION_SCHEMA.ROUTINES
WHERE ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
`;
params = [];
break;
}
default:
throw new Error(`프로시저 목록 조회 미지원 DB: ${dbType}`);
}
const result = await executeExternalQuery(connectionId, query, params);
return (result.rows || []).map((r: any) => ({
name: r.name || r.NAME,
schema: r.schema || r.SCHEMA || "",
type: (r.type || r.TYPE || "FUNCTION").toUpperCase() as "PROCEDURE" | "FUNCTION",
returnType: r.return_type || r.RETURN_TYPE || undefined,
}));
}
/**
* /
*/
async getProcedureParameters(
procedureName: string,
dbSource: "internal" | "external",
connectionId?: number,
schema?: string
): Promise<ProcedureParameterInfo[]> {
if (dbSource === "external" && connectionId) {
return this.getExternalProcedureParameters(
connectionId,
procedureName,
schema
);
}
return this.getInternalProcedureParameters(procedureName, schema);
}
private async getInternalProcedureParameters(
procedureName: string,
schema?: string
): Promise<ProcedureParameterInfo[]> {
const targetSchema = schema || "public";
// PostgreSQL의 specific_name은 routine_name + OID 형태이므로 서브쿼리로 매칭
const query = `
SELECT
p.parameter_name AS name,
p.ordinal_position AS position,
p.data_type,
p.parameter_mode AS mode,
p.parameter_default AS default_value
FROM information_schema.parameters p
WHERE p.specific_schema = $1
AND p.specific_name IN (
SELECT r.specific_name FROM information_schema.routines r
WHERE r.routine_schema = $1 AND r.routine_name = $2
LIMIT 1
)
AND p.parameter_name IS NOT NULL
ORDER BY p.ordinal_position
`;
const rows = await db.query(query, [targetSchema, procedureName]);
return rows.map((r: any) => ({
name: r.name,
position: parseInt(r.position, 10),
dataType: r.data_type,
mode: this.normalizeParamMode(r.mode),
defaultValue: r.default_value || undefined,
}));
}
private async getExternalProcedureParameters(
connectionId: number,
procedureName: string,
schema?: string
): Promise<ProcedureParameterInfo[]> {
const poolInfo = await getExternalPool(connectionId);
const dbType = poolInfo.dbType.toLowerCase();
let query: string;
let params: any[];
switch (dbType) {
case "postgresql": {
const targetSchema = schema || "public";
query = `
SELECT
p.parameter_name AS name,
p.ordinal_position AS position,
p.data_type,
p.parameter_mode AS mode,
p.parameter_default AS default_value
FROM information_schema.parameters p
WHERE p.specific_schema = $1
AND p.specific_name IN (
SELECT r.specific_name FROM information_schema.routines r
WHERE r.routine_schema = $1 AND r.routine_name = $2
LIMIT 1
)
AND p.parameter_name IS NOT NULL
ORDER BY p.ordinal_position
`;
params = [targetSchema, procedureName];
break;
}
case "mysql":
case "mariadb": {
query = `
SELECT
PARAMETER_NAME AS name,
ORDINAL_POSITION AS position,
DATA_TYPE AS data_type,
PARAMETER_MODE AS mode,
'' AS default_value
FROM information_schema.PARAMETERS
WHERE SPECIFIC_SCHEMA = DATABASE()
AND SPECIFIC_NAME = ?
AND PARAMETER_NAME IS NOT NULL
ORDER BY ORDINAL_POSITION
`;
params = [procedureName];
break;
}
case "mssql": {
query = `
SELECT
PARAMETER_NAME AS name,
ORDINAL_POSITION AS position,
DATA_TYPE AS data_type,
PARAMETER_MODE AS mode,
'' AS default_value
FROM INFORMATION_SCHEMA.PARAMETERS
WHERE SPECIFIC_NAME = @p1
AND PARAMETER_NAME IS NOT NULL
ORDER BY ORDINAL_POSITION
`;
params = [procedureName];
break;
}
default:
throw new Error(`파라미터 조회 미지원 DB: ${dbType}`);
}
const result = await executeExternalQuery(connectionId, query, params);
return (result.rows || []).map((r: any) => ({
name: (r.name || r.NAME || "").replace(/^@/, ""),
position: parseInt(r.position || r.POSITION || "0", 10),
dataType: r.data_type || r.DATA_TYPE || "unknown",
mode: this.normalizeParamMode(r.mode || r.MODE),
defaultValue: r.default_value || r.DEFAULT_VALUE || undefined,
}));
}
/**
* /
* DB는 client를 , DB는
*/
async executeProcedure(
config: FlowProcedureConfig,
recordData: Record<string, any>,
client?: any
): Promise<{ success: boolean; result?: any; error?: string }> {
const paramValues = this.resolveParameters(config.parameters, recordData);
if (config.dbSource === "internal") {
return this.executeInternalProcedure(config, paramValues, client);
}
if (!config.connectionId) {
throw new Error("외부 DB 프로시저 호출에 connectionId가 필요합니다");
}
return this.executeExternalProcedure(config, paramValues);
}
/**
* DB ( client )
*/
private async executeInternalProcedure(
config: FlowProcedureConfig,
paramValues: any[],
client?: any
): Promise<{ success: boolean; result?: any; error?: string }> {
const schema = config.procedureSchema || "public";
const safeName = this.sanitizeName(config.procedureName);
const safeSchema = this.sanitizeName(schema);
const qualifiedName = `${safeSchema}.${safeName}`;
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
let sql: string;
if (config.callType === "function") {
// SELECT * FROM fn()을 사용하여 OUT 파라미터를 개별 컬럼으로 반환
sql = `SELECT * FROM ${qualifiedName}(${placeholders})`;
} else {
sql = `CALL ${qualifiedName}(${placeholders})`;
}
try {
const executor = client || db;
const result = client
? await client.query(sql, paramValues)
: await db.query(sql, paramValues);
const rows = client ? result.rows : result;
return { success: true, result: rows };
} catch (error: any) {
throw new Error(
`프로시저 실행 실패 [${qualifiedName}]: ${error.message}`
);
}
}
/**
* DB
*/
private async executeExternalProcedure(
config: FlowProcedureConfig,
paramValues: any[]
): Promise<{ success: boolean; result?: any; error?: string }> {
const connectionId = config.connectionId!;
const poolInfo = await getExternalPool(connectionId);
const dbType = poolInfo.dbType.toLowerCase();
const safeName = this.sanitizeName(config.procedureName);
const safeSchema = config.procedureSchema
? this.sanitizeName(config.procedureSchema)
: null;
let sql: string;
switch (dbType) {
case "postgresql": {
const qualifiedName = safeSchema
? `${safeSchema}.${safeName}`
: safeName;
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
sql =
config.callType === "function"
? `SELECT * FROM ${qualifiedName}(${placeholders})`
: `CALL ${qualifiedName}(${placeholders})`;
break;
}
case "mysql":
case "mariadb": {
const placeholders = paramValues.map(() => "?").join(", ");
sql = `CALL ${safeName}(${placeholders})`;
break;
}
case "mssql": {
const paramList = paramValues
.map((_, i) => `@p${i + 1}`)
.join(", ");
sql = `EXEC ${safeName} ${paramList}`;
break;
}
default:
throw new Error(`프로시저 실행 미지원 DB: ${dbType}`);
}
try {
const result = await executeExternalQuery(connectionId, sql, paramValues);
return { success: true, result: result.rows };
} catch (error: any) {
throw new Error(
`외부 프로시저 실행 실패 [${safeName}]: ${error.message}`
);
}
}
/**
*
*/
private resolveParameters(
params: FlowProcedureParam[],
recordData: Record<string, any>
): any[] {
const inParams = params.filter((p) => p.mode === "IN" || p.mode === "INOUT");
return inParams.map((param) => {
switch (param.source) {
case "record_field":
if (!param.field) {
throw new Error(`파라미터 ${param.name}: 레코드 필드가 지정되지 않았습니다`);
}
return recordData[param.field] ?? null;
case "static":
return param.value ?? null;
case "step_variable":
return recordData[param.field || param.name] ?? param.value ?? null;
default:
return null;
}
});
}
/**
* (/) SQL Injection
*/
private sanitizeName(name: string): string {
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
throw new Error(`유효하지 않은 이름: ${name}`);
}
return name;
}
/**
*
*/
private normalizeParamMode(mode: string | null): "IN" | "OUT" | "INOUT" {
if (!mode) return "IN";
const upper = mode.toUpperCase();
if (upper === "OUT") return "OUT";
if (upper === "INOUT") return "INOUT";
return "IN";
}
}

View File

@ -11,6 +11,7 @@
import { query, queryOne, transaction } from "../database/db"; import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import axios from "axios"; import axios from "axios";
import { FlowProcedureService } from "./flowProcedureService";
// ===== 타입 정의 ===== // ===== 타입 정의 =====
@ -36,6 +37,7 @@ export type NodeType =
| "emailAction" // 이메일 발송 액션 | "emailAction" // 이메일 발송 액션
| "scriptAction" // 스크립트 실행 액션 | "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션 | "httpRequestAction" // HTTP 요청 액션
| "procedureCallAction" // 프로시저/함수 호출 액션
| "comment" | "comment"
| "log"; | "log";
@ -663,6 +665,9 @@ export class NodeFlowExecutionService {
case "httpRequestAction": case "httpRequestAction":
return this.executeHttpRequestAction(node, inputData, context); return this.executeHttpRequestAction(node, inputData, context);
case "procedureCallAction":
return this.executeProcedureCallAction(node, inputData, context, client);
case "comment": case "comment":
case "log": case "log":
// 로그/코멘트는 실행 없이 통과 // 로그/코멘트는 실행 없이 통과
@ -4856,4 +4861,105 @@ export class NodeFlowExecutionService {
); );
} }
} }
/**
* /
*/
private static async executeProcedureCallAction(
node: FlowNode,
inputData: any,
context: ExecutionContext,
client?: any
): Promise<any> {
const {
dbSource = "internal",
connectionId,
procedureName,
procedureSchema = "public",
callType = "function",
parameters = [],
} = node.data;
logger.info(
`🔧 프로시저 호출 노드 실행: ${node.data.displayName || node.id}`
);
logger.info(
` 프로시저: ${procedureSchema}.${procedureName} (${callType}), DB: ${dbSource}`
);
if (!procedureName) {
throw new Error("프로시저/함수가 선택되지 않았습니다.");
}
const dataArray = Array.isArray(inputData)
? inputData
: inputData
? [inputData]
: [{}];
const procedureService = new FlowProcedureService();
const results: any[] = [];
const config = {
type: "procedure" as const,
dbSource: dbSource as "internal" | "external",
connectionId,
procedureName,
procedureSchema,
callType: callType as "procedure" | "function",
parameters: parameters.map((p: any) => ({
name: p.name,
dataType: p.dataType,
mode: p.mode || "IN",
source: p.source || "static",
field: p.field,
value: p.value,
})),
};
for (const record of dataArray) {
try {
logger.info(` 입력 레코드 키: ${Object.keys(record).join(", ")}`);
const execResult = await procedureService.executeProcedure(
config,
record,
dbSource === "internal" ? client : undefined
);
logger.info(` ✅ 프로시저 실행 성공: ${procedureName}`);
// 프로시저 반환값을 레코드에 평탄화하여 다음 노드에서 필드로 참조 가능하게 함
let flatResult: Record<string, any> = {};
if (Array.isArray(execResult.result) && execResult.result.length > 0) {
const row = execResult.result[0];
for (const [key, val] of Object.entries(row)) {
// 함수명과 동일한 키(SELECT fn() 결과)는 _procedureReturn으로 매핑
if (key === procedureName) {
flatResult["_procedureReturn"] = val;
} else {
flatResult[key] = val;
}
}
logger.info(` 반환 필드: ${Object.keys(flatResult).join(", ")}`);
}
results.push({
...record,
...flatResult,
_procedureResult: execResult.result,
_procedureSuccess: true,
});
} catch (error: any) {
logger.error(` ❌ 프로시저 실행 실패: ${error.message}`);
throw error;
}
}
logger.info(
`🔧 프로시저 호출 완료: ${results.length}건 처리`
);
return results;
}
} }

View File

@ -260,6 +260,7 @@ export interface FlowStepDataList {
// 데이터 이동 요청 // 데이터 이동 요청
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;
@ -277,6 +278,7 @@ export interface SqlWhereResult {
export type FlowIntegrationType = export type FlowIntegrationType =
| "internal" // 내부 DB (기본값) | "internal" // 내부 DB (기본값)
| "external_db" // 외부 DB | "external_db" // 외부 DB
| "procedure" // 프로시저/함수 호출
| "rest_api" // REST API (추후 구현) | "rest_api" // REST API (추후 구현)
| "webhook" // Webhook (추후 구현) | "webhook" // Webhook (추후 구현)
| "hybrid"; // 복합 연동 (추후 구현) | "hybrid"; // 복합 연동 (추후 구현)
@ -340,8 +342,48 @@ export interface FlowExternalDbIntegrationConfig {
customQuery?: string; // operation이 'custom'인 경우 사용 customQuery?: string; // operation이 'custom'인 경우 사용
} }
// 프로시저 호출 파라미터 정의
export interface FlowProcedureParam {
name: string;
dataType: string;
mode: "IN" | "OUT" | "INOUT";
source: "record_field" | "static" | "step_variable";
field?: string; // source가 record_field인 경우: 레코드 컬럼명
value?: string; // source가 static인 경우: 고정값
}
// 프로시저 호출 설정 (integration_config JSON)
export interface FlowProcedureConfig {
type: "procedure";
dbSource: "internal" | "external";
connectionId?: number; // 외부 DB인 경우 external_db_connections.id
procedureName: string;
procedureSchema?: string; // 스키마명 (기본: public)
callType: "procedure" | "function"; // CALL vs SELECT
parameters: FlowProcedureParam[];
}
// 프로시저/함수 목록 항목
export interface ProcedureListItem {
name: string;
schema: string;
type: "PROCEDURE" | "FUNCTION";
returnType?: string;
}
// 프로시저 파라미터 정보
export interface ProcedureParameterInfo {
name: string;
position: number;
dataType: string;
mode: "IN" | "OUT" | "INOUT";
defaultValue?: string;
}
// 연동 설정 통합 타입 // 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가 export type FlowIntegrationConfig =
| FlowExternalDbIntegrationConfig
| FlowProcedureConfig;
// 연동 실행 컨텍스트 // 연동 실행 컨텍스트
export interface FlowIntegrationContext { export interface FlowIntegrationContext {

View File

@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
BatchAPI, BatchAPI,
@ -133,7 +134,7 @@ export default function BatchCreatePage() {
setFromColumns(Array.isArray(columns) ? columns : []); setFromColumns(Array.isArray(columns) ? columns : []);
} catch (error) { } catch (error) {
console.error("FROM 컬럼 목록 로드 실패:", error); console.error("FROM 컬럼 목록 로드 실패:", error);
toast.error("컬럼 목록을 불러오는데 실패했습니다."); showErrorToast("컬럼 목록을 불러오는 데 실패했습니다", error, { guidance: "테이블 정보를 확인해 주세요." });
} }
}; };
@ -242,7 +243,7 @@ export default function BatchCreatePage() {
router.push("/admin/batchmng"); router.push("/admin/batchmng");
} catch (error) { } catch (error) {
console.error("배치 설정 저장 실패:", error); console.error("배치 설정 저장 실패:", error);
toast.error("배치 설정 저장에 실패했습니다."); showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -10,6 +10,7 @@ import {
Database Database
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
BatchAPI, BatchAPI,
@ -75,7 +76,9 @@ export default function BatchManagementPage() {
} }
} catch (error) { } catch (error) {
console.error("배치 실행 실패:", error); console.error("배치 실행 실패:", error);
toast.error("배치 실행 중 오류가 발생했습니다."); showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setExecutingBatch(null); setExecutingBatch(null);
} }

View File

@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react"; import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
ExternalCallConfigAPI, ExternalCallConfigAPI,
ExternalCallConfig, ExternalCallConfig,
@ -57,11 +58,15 @@ export default function ExternalCallConfigsPage() {
if (response.success) { if (response.success) {
setConfigs(response.data || []); setConfigs(response.data || []);
} else { } else {
toast.error(response.message || "외부 호출 설정 조회 실패"); showErrorToast("외부 호출 설정 조회에 실패했습니다", response.message, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
});
} }
} catch (error) { } catch (error) {
console.error("외부 호출 설정 조회 오류:", error); console.error("외부 호출 설정 조회 오류:", error);
toast.error("외부 호출 설정 조회 중 오류가 발생했습니다."); showErrorToast("외부 호출 설정 조회에 실패했습니다", error, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -113,11 +118,15 @@ export default function ExternalCallConfigsPage() {
toast.success("외부 호출 설정이 삭제되었습니다."); toast.success("외부 호출 설정이 삭제되었습니다.");
fetchConfigs(); fetchConfigs();
} else { } else {
toast.error(response.message || "외부 호출 설정 삭제 실패"); showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.message, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} }
} catch (error) { } catch (error) {
console.error("외부 호출 설정 삭제 오류:", error); console.error("외부 호출 설정 삭제 오류:", error);
toast.error("외부 호출 설정 삭제 중 오류가 발생했습니다."); showErrorToast("외부 호출 설정 삭제에 실패했습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} finally { } finally {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setConfigToDelete(null); setConfigToDelete(null);
@ -138,7 +147,9 @@ export default function ExternalCallConfigsPage() {
} }
} catch (error) { } catch (error) {
console.error("외부 호출 설정 테스트 오류:", error); console.error("외부 호출 설정 테스트 오류:", error);
toast.error("외부 호출 설정 테스트 중 오류가 발생했습니다."); showErrorToast("외부 호출 테스트 실행에 실패했습니다", error, {
guidance: "URL과 설정을 확인해 주세요.",
});
} }
}; };

View File

@ -130,6 +130,8 @@ export default function FlowEditorPage() {
tableName: step.tableName, tableName: step.tableName,
count: stepCounts[step.id] || 0, count: stepCounts[step.id] || 0,
condition: step.conditionJson, condition: step.conditionJson,
integrationType: (step as any).integrationType,
procedureName: (step as any).integrationConfig?.procedureName,
}, },
})); }));

View File

@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { BatchManagementAPI } from "@/lib/api/batchManagement"; import { BatchManagementAPI } from "@/lib/api/batchManagement";
// 타입 정의 // 타입 정의
@ -469,7 +470,9 @@ export default function BatchManagementNewPage() {
} }
} catch (error) { } catch (error) {
console.error("배치 저장 오류:", error); console.error("배치 저장 오류:", error);
toast.error("배치 저장 중 오류가 발생했습니다."); showErrorToast("배치 설정 저장에 실패했습니다", error, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
return; return;
} else if (batchType === "db-to-restapi") { } else if (batchType === "db-to-restapi") {
@ -558,7 +561,9 @@ export default function BatchManagementNewPage() {
} }
} catch (error) { } catch (error) {
console.error("배치 저장 오류:", error); console.error("배치 저장 오류:", error);
toast.error("배치 저장 중 오류가 발생했습니다."); showErrorToast("배치 설정 저장에 실패했습니다", error, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
return; return;
} }

View File

@ -75,7 +75,7 @@ export default function BatchManagementPage() {
setJobs(data); setJobs(data);
} catch (error) { } catch (error) {
console.error("배치 작업 목록 조회 오류:", error); console.error("배치 작업 목록 조회 오류:", error);
toast.error("배치 작업 목록을 불러오는데 실패했습니다."); showErrorToast("배치 작업 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -150,7 +150,7 @@ export default function BatchManagementPage() {
loadJobs(); loadJobs();
} catch (error) { } catch (error) {
console.error("배치 작업 삭제 오류:", error); console.error("배치 작업 삭제 오류:", error);
toast.error("배치 작업 삭제에 실패했습니다."); showErrorToast("배치 작업 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };
@ -160,7 +160,7 @@ export default function BatchManagementPage() {
toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`); toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`);
} catch (error) { } catch (error) {
console.error("배치 작업 실행 오류:", error); console.error("배치 작업 실행 오류:", error);
toast.error("배치 작업 실행에 실패했습니다."); showErrorToast("배치 작업 실행에 실패했습니다", error, { guidance: "배치 설정을 확인해 주세요." });
} }
}; };

View File

@ -45,6 +45,7 @@ import {
GripVertical, GripVertical,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill"; import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
@ -97,7 +98,7 @@ export default function AutoFillTab() {
} }
} catch (error) { } catch (error) {
console.error("그룹 목록 로드 실패:", error); console.error("그룹 목록 로드 실패:", error);
toast.error("그룹 목록을 불러오는데 실패했습니다."); showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -269,7 +270,7 @@ export default function AutoFillTab() {
toast.error(response.error || "저장에 실패했습니다."); toast.error(response.error || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };

View File

@ -33,6 +33,7 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation"; import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
@ -102,7 +103,7 @@ export default function CascadingRelationsTab() {
setRelations(response.data); setRelations(response.data);
} }
} catch (error) { } catch (error) {
toast.error("연쇄 관계 목록 조회에 실패했습니다."); showErrorToast("연쇄 관계 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -431,7 +432,7 @@ export default function CascadingRelationsTab() {
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("연쇄 관계 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -452,7 +453,7 @@ export default function CascadingRelationsTab() {
toast.error(response.message || "삭제에 실패했습니다."); toast.error(response.message || "삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("삭제 중 오류가 발생했습니다."); showErrorToast("연쇄 관계 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -43,6 +43,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
cascadingConditionApi, cascadingConditionApi,
CascadingCondition, CascadingCondition,
@ -170,7 +171,7 @@ export default function ConditionTab() {
toast.error(response.error || "삭제에 실패했습니다."); toast.error(response.error || "삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("삭제 중 오류가 발생했습니다."); showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setDeletingConditionId(null); setDeletingConditionId(null);
@ -206,7 +207,7 @@ export default function ConditionTab() {
toast.error(response.error || "저장에 실패했습니다."); toast.error(response.error || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };

View File

@ -29,6 +29,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react"; import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { import {
hierarchyColumnApi, hierarchyColumnApi,
@ -300,7 +301,7 @@ export default function HierarchyColumnTab() {
} }
} catch (error) { } catch (error) {
console.error("저장 에러:", error); console.error("저장 에러:", error);
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("계층구조 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };
@ -319,7 +320,7 @@ export default function HierarchyColumnTab() {
} }
} catch (error) { } catch (error) {
console.error("삭제 에러:", error); console.error("삭제 에러:", error);
toast.error("삭제 중 오류가 발생했습니다."); showErrorToast("계층구조 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react"; import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes"; import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
import { AVAILABLE_COMPONENTS, getComponentInfo } from "@/lib/utils/availableComponents"; import { AVAILABLE_COMPONENTS, getComponentInfo } from "@/lib/utils/availableComponents";
@ -148,7 +149,7 @@ export default function EditWebTypePage() {
toast.success("웹타입이 성공적으로 수정되었습니다."); toast.success("웹타입이 성공적으로 수정되었습니다.");
router.push(`/admin/standards/${webType}`); router.push(`/admin/standards/${webType}`);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다."); showErrorToast("웹타입 수정에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };

View File

@ -19,6 +19,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react"; import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import Link from "next/link"; import Link from "next/link";
@ -90,7 +91,7 @@ export default function WebTypesManagePage() {
await deleteWebType(webType); await deleteWebType(webType);
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`); toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다."); showErrorToast("웹타입 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -37,6 +37,7 @@ import {
RefreshCw RefreshCw
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection"; import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
import CollectionConfigModal from "@/components/admin/CollectionConfigModal"; import CollectionConfigModal from "@/components/admin/CollectionConfigModal";
@ -69,7 +70,7 @@ export default function CollectionManagementPage() {
setConfigs(data); setConfigs(data);
} catch (error) { } catch (error) {
console.error("수집 설정 목록 조회 오류:", error); console.error("수집 설정 목록 조회 오류:", error);
toast.error("수집 설정 목록을 불러오는데 실패했습니다."); showErrorToast("수집 설정 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -131,7 +132,7 @@ export default function CollectionManagementPage() {
toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`); toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`);
} catch (error) { } catch (error) {
console.error("수집 작업 실행 오류:", error); console.error("수집 작업 실행 오류:", error);
toast.error("수집 작업 실행에 실패했습니다."); showErrorToast("수집 작업 실행에 실패했습니다", error, { guidance: "수집 설정을 확인해 주세요." });
} }
}; };

View File

@ -6,6 +6,7 @@ import DataFlowList from "@/components/dataflow/DataFlowList";
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
@ -35,7 +36,7 @@ export default function DataFlowPage() {
toast.success("플로우를 불러왔습니다."); toast.success("플로우를 불러왔습니다.");
} catch (error: any) { } catch (error: any) {
console.error("❌ 플로우 불러오기 실패:", error); console.error("❌ 플로우 불러오기 실패:", error);
toast.error(error.message || "플로우를 불러오는데 실패했습니다."); showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} }
}; };

View File

@ -24,6 +24,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement"; import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
@ -331,11 +332,15 @@ export default function TableManagementPage() {
setTables(response.data.data); setTables(response.data.data);
toast.success("테이블 목록을 성공적으로 로드했습니다."); toast.success("테이블 목록을 성공적으로 로드했습니다.");
} else { } else {
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다."); showErrorToast("테이블 목록을 불러오는 데 실패했습니다", response.data.message, {
guidance: "네트워크 연결을 확인해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("테이블 목록 로드 실패:", error); // console.error("테이블 목록 로드 실패:", error);
toast.error("테이블 목록 로드 중 오류가 발생했습니다."); showErrorToast("테이블 목록을 불러오는 데 실패했습니다", error, {
guidance: "네트워크 연결을 확인해 주세요.",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -403,11 +408,15 @@ export default function TableManagementPage() {
setTotalColumns(data.total || processedColumns.length); setTotalColumns(data.total || processedColumns.length);
toast.success("컬럼 정보를 성공적으로 로드했습니다."); toast.success("컬럼 정보를 성공적으로 로드했습니다.");
} else { } else {
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다."); showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", response.data.message, {
guidance: "네트워크 연결을 확인해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("컬럼 타입 정보 로드 실패:", error); // console.error("컬럼 타입 정보 로드 실패:", error);
toast.error("컬럼 정보 로드 중 오류가 발생했습니다."); showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", error, {
guidance: "네트워크 연결을 확인해 주세요.",
});
} finally { } finally {
setColumnsLoading(false); setColumnsLoading(false);
} }
@ -777,11 +786,15 @@ export default function TableManagementPage() {
loadColumnTypes(selectedTable); loadColumnTypes(selectedTable);
}, 1000); }, 1000);
} else { } else {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("컬럼 설정 저장 실패:", error); // console.error("컬럼 설정 저장 실패:", error);
toast.error("컬럼 설정 저장 중 오류가 발생했습니다."); showErrorToast("컬럼 설정 저장에 실패했습니다", error, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
}; };
@ -980,12 +993,16 @@ export default function TableManagementPage() {
loadColumnTypes(selectedTable, 1, pageSize); loadColumnTypes(selectedTable, 1, pageSize);
}, 1000); }, 1000);
} else { } else {
toast.error(response.data.message || "설정 저장에 실패했습니다."); showErrorToast("설정 저장에 실패했습니다", response.data.message, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} }
} }
} catch (error) { } catch (error) {
// console.error("설정 저장 실패:", error); // console.error("설정 저장 실패:", error);
toast.error("설정 저장 중 오류가 발생했습니다."); showErrorToast("설정 저장에 실패했습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -1091,7 +1108,9 @@ export default function TableManagementPage() {
toast.error(response.data.message || "PK 설정 실패"); toast.error(response.data.message || "PK 설정 실패");
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); showErrorToast("PK 설정에 실패했습니다", error, {
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setPkDialogOpen(false); setPkDialogOpen(false);
} }
@ -1115,7 +1134,9 @@ export default function TableManagementPage() {
toast.error(response.data.message || "인덱스 설정 실패"); toast.error(response.data.message || "인덱스 설정 실패");
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); showErrorToast("인덱스 설정에 실패했습니다", error, {
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
});
} }
}, },
[selectedTable, loadConstraints], [selectedTable, loadConstraints],
@ -1154,10 +1175,14 @@ export default function TableManagementPage() {
), ),
); );
} else { } else {
toast.error(response.data.message || "UNIQUE 설정 실패"); showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", response.data.message, {
guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.",
});
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", error, {
guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.",
});
} }
}, },
[selectedTable], [selectedTable],
@ -1188,12 +1213,14 @@ export default function TableManagementPage() {
), ),
); );
} else { } else {
toast.error(response.data.message || "NOT NULL 설정 실패"); showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", response.data.message, {
guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.",
});
} }
} catch (error: any) { } catch (error: any) {
toast.error( showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", error, {
error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.",
); });
} }
}, },
[selectedTable], [selectedTable],
@ -1225,10 +1252,14 @@ export default function TableManagementPage() {
// 테이블 목록 새로고침 // 테이블 목록 새로고침
await loadTables(); await loadTables();
} else { } else {
toast.error(result.message || "테이블 삭제에 실패했습니다."); showErrorToast("테이블 삭제에 실패했습니다", result.message, {
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다."); showErrorToast("테이블 삭제에 실패했습니다", error, {
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
@ -1308,7 +1339,9 @@ export default function TableManagementPage() {
setSelectedTableIds(new Set()); setSelectedTableIds(new Set());
await loadTables(); await loadTables();
} catch (error: any) { } catch (error: any) {
toast.error("테이블 삭제 중 오류가 발생했습니다."); showErrorToast("테이블 삭제에 실패했습니다", error, {
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);

View File

@ -9,6 +9,7 @@ import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
import { LayerDefinition } from "@/types/screen-management"; import { LayerDefinition } from "@/types/screen-management";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal"; import { EditModal } from "@/components/screen/EditModal";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
@ -219,7 +220,7 @@ function ScreenViewPage() {
} catch (error) { } catch (error) {
console.error("화면 로드 실패:", error); console.error("화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다."); setError("화면을 불러오는데 실패했습니다.");
toast.error("화면을 불러오는데 실패했습니다."); showErrorToast("화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -8,6 +8,7 @@ import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
@ -135,7 +136,7 @@ function PopScreenViewPage() {
} catch (error) { } catch (error) {
console.error("[POP] 화면 로드 실패:", error); console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다."); setError("화면을 불러오는데 실패했습니다.");
toast.error("화면을 불러오는데 실패했습니다."); showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -11,6 +11,7 @@ import { downloadFile } from "@/lib/api/file";
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { formatFileSize } from "@/lib/utils"; import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
File, File,
FileText, FileText,
@ -134,7 +135,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
toast.success(`파일 다운로드 시작: ${file.realFileName}`); toast.success(`파일 다운로드 시작: ${file.realFileName}`);
} catch (error) { } catch (error) {
console.error("파일 다운로드 오류:", error); console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드에 실패했습니다."); showErrorToast("파일 다운로드에 실패했습니다", error, { guidance: "파일이 존재하는지 확인하고 다시 시도해 주세요." });
} }
}; };

View File

@ -20,6 +20,7 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch"; import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
@ -123,7 +124,7 @@ export default function AdvancedBatchModal({
setConnections(list); setConnections(list);
} catch (error) { } catch (error) {
console.error("연결 목록 조회 오류:", error); console.error("연결 목록 조회 오류:", error);
toast.error("연결 목록을 불러오는데 실패했습니다."); showErrorToast("연결 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} }
}; };
@ -190,7 +191,7 @@ export default function AdvancedBatchModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("배치 저장 오류:", error); console.error("배치 저장 오류:", error);
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다."); showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -33,6 +33,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { format } from "date-fns"; import { format } from "date-fns";
import { ko } from "date-fns/locale"; import { ko } from "date-fns/locale";
import { ddlApi } from "../../lib/api/ddl"; import { ddlApi } from "../../lib/api/ddl";
@ -71,7 +72,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
setStatistics(statsResult); setStatistics(statsResult);
} catch (error) { } catch (error) {
// console.error("DDL 로그 로드 실패:", error); // console.error("DDL 로그 로드 실패:", error);
toast.error("DDL 로그를 불러오는데 실패했습니다."); showErrorToast("DDL 로그를 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
if (showLoading) setLoading(false); if (showLoading) setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -108,7 +109,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
loadData(false); loadData(false);
} catch (error) { } catch (error) {
// console.error("로그 정리 실패:", error); // console.error("로그 정리 실패:", error);
toast.error("로그 정리에 실패했습니다."); showErrorToast("로그 정리에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
ExternalCallConfigAPI, ExternalCallConfigAPI,
ExternalCallConfig, ExternalCallConfig,
@ -259,7 +260,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
} }
} catch (error) { } catch (error) {
console.error("외부 호출 설정 저장 오류:", error); console.error("외부 호출 설정 저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("외부 호출 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -32,6 +32,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { LayoutCategory } from "@/types/layout"; import { LayoutCategory } from "@/types/layout";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
interface LayoutFormModalProps { interface LayoutFormModalProps {
open: boolean; open: boolean;
@ -210,7 +211,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
success: false, success: false,
message: result.message || "레이아웃 생성에 실패했습니다.", message: result.message || "레이아웃 생성에 실패했습니다.",
}); });
toast.error("레이아웃 생성 실패"); showErrorToast("레이아웃 생성에 실패했습니다", result.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
} catch (error) { } catch (error) {
console.error("레이아웃 생성 오류:", error); console.error("레이아웃 생성 오류:", error);
@ -218,7 +219,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
success: false, success: false,
message: "서버 오류가 발생했습니다.", message: "서버 오류가 발생했습니다.",
}); });
toast.error("서버 오류"); showErrorToast("레이아웃 생성에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import {
Dialog, Dialog,
@ -94,7 +95,7 @@ export function MenuCopyDialog({
} }
} catch (error) { } catch (error) {
console.error("회사 목록 조회 실패:", error); console.error("회사 목록 조회 실패:", error);
toast.error("회사 목록을 불러올 수 없습니다"); showErrorToast("회사 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setLoadingCompanies(false); setLoadingCompanies(false);
} }
@ -160,7 +161,7 @@ export function MenuCopyDialog({
} }
} catch (error: any) { } catch (error: any) {
console.error("메뉴 복사 오류:", error); console.error("메뉴 복사 오류:", error);
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다"); showErrorToast("메뉴 복사에 실패했습니다", error, { guidance: "복사 대상과 설정을 확인해 주세요." });
} finally { } finally {
setCopying(false); setCopying(false);
} }

View File

@ -20,6 +20,7 @@ import { toast } from "sonner";
import { ChevronDown, Search } from "lucide-react"; import { ChevronDown, Search } from "lucide-react";
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { MenuIconPicker } from "./MenuIconPicker";
interface Company { interface Company {
company_code: string; company_code: string;
@ -77,6 +78,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
status: "ACTIVE", status: "ACTIVE",
companyCode: parentCompanyCode || "none", companyCode: parentCompanyCode || "none",
langKey: "", langKey: "",
menuIcon: "",
}); });
// 화면 할당 관련 상태 // 화면 할당 관련 상태
@ -275,6 +277,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const status = menu.status || menu.STATUS || "active"; const status = menu.status || menu.STATUS || "active";
const companyCode = menu.company_code || menu.COMPANY_CODE || ""; const companyCode = menu.company_code || menu.COMPANY_CODE || "";
const langKey = menu.lang_key || menu.LANG_KEY || ""; const langKey = menu.lang_key || menu.LANG_KEY || "";
const menuIcon = menu.menu_icon || menu.MENU_ICON || "";
// 메뉴 타입 변환 (admin/user -> 0/1) // 메뉴 타입 변환 (admin/user -> 0/1)
let convertedMenuType = menuType; let convertedMenuType = menuType;
@ -307,7 +310,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
menuType: convertedMenuType, menuType: convertedMenuType,
status: convertedStatus, status: convertedStatus,
companyCode: companyCode, companyCode: companyCode,
langKey: langKey, // 다국어 키 설정 langKey: langKey,
menuIcon: menuIcon,
}); });
// URL 타입 설정 // URL 타입 설정
@ -420,9 +424,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
menuDesc: "", menuDesc: "",
seq: 1, seq: 1,
menuType: defaultMenuType, menuType: defaultMenuType,
status: "ACTIVE", // 기본값은 활성화 status: "ACTIVE",
companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 companyCode: parentCompanyCode || "none",
langKey: "", // 다국어 키 초기화 langKey: "",
menuIcon: "",
}); });
// console.log("메뉴 등록 기본값 설정:", { // console.log("메뉴 등록 기본값 설정:", {
@ -839,6 +844,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
/> />
</div> </div>
<MenuIconPicker
value={formData.menuIcon || ""}
onChange={(iconName) => handleInputChange("menuIcon", iconName)}
/>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label> <Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>

View File

@ -0,0 +1,553 @@
"use client";
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Search, X, ChevronDown } from "lucide-react";
import * as LucideIcons from "lucide-react";
type IconComponent = React.FC<{ className?: string }>;
// lucide-react에서 아이콘 컴포넌트만 필터링 (유틸 함수, 타입 등 제외)
const EXCLUDED_EXPORTS = new Set([
"createLucideIcon",
"defaultAttributes",
"Icon",
"icons",
"default",
]);
// PascalCase인지 확인 (아이콘 컴포넌트는 모두 PascalCase)
const isPascalCase = (str: string): boolean => /^[A-Z][a-zA-Z0-9]*$/.test(str);
// 한글 키워드 매핑 (자주 쓰는 아이콘에 한글 검색어 추가)
const KOREAN_KEYWORDS: Record<string, string[]> = {
Home: ["홈", "메인", "대시보드"],
FileText: ["문서", "파일", "텍스트"],
Users: ["사용자", "회원", "인사", "팀"],
User: ["사용자", "회원", "개인"],
Settings: ["설정", "관리", "시스템"],
Shield: ["보안", "권한", "관리자"],
Package: ["제품", "품목", "패키지", "상품"],
BarChart3: ["통계", "차트", "분석", "리포트"],
BarChart2: ["통계", "차트", "분석"],
BarChart: ["통계", "차트"],
Building2: ["회사", "조직", "건물", "부서"],
Building: ["회사", "건물"],
ShoppingCart: ["영업", "판매", "주문", "장바구니"],
ShoppingBag: ["쇼핑", "가방", "구매"],
Truck: ["물류", "배송", "운송", "출하"],
Warehouse: ["창고", "재고", "입고"],
Factory: ["생산", "공장", "제조"],
Wrench: ["설비", "유지보수", "수리", "도구"],
ClipboardCheck: ["품질", "검사", "체크리스트"],
ClipboardList: ["작업지시", "지시서", "할일"],
Clipboard: ["클립보드", "복사"],
DollarSign: ["회계", "금액", "비용", "가격"],
Receipt: ["영수증", "청구", "전표"],
Calendar: ["일정", "캘린더", "날짜"],
CalendarDays: ["일정", "캘린더", "날짜", "일"],
Clock: ["시간", "이력", "히스토리"],
FolderOpen: ["폴더", "분류", "카테고리"],
Folder: ["폴더", "분류", "그룹"],
FolderPlus: ["폴더추가", "분류추가"],
Database: ["데이터", "DB", "저장소"],
Globe: ["글로벌", "다국어", "웹", "세계"],
Mail: ["메일", "이메일"],
Bell: ["알림", "벨", "통지"],
BellRing: ["알림", "벨", "울림"],
Search: ["검색", "조회", "찾기"],
ListOrdered: ["목록", "리스트", "순서"],
List: ["목록", "리스트"],
LayoutGrid: ["그리드", "레이아웃", "화면"],
LayoutDashboard: ["대시보드", "레이아웃"],
Tag: ["태그", "라벨", "분류"],
Tags: ["태그", "라벨", "분류", "복수"],
BookOpen: ["문서", "매뉴얼", "가이드"],
Book: ["책", "문서"],
Boxes: ["BOM", "자재", "부품", "구성"],
Box: ["박스", "상자", "제품"],
GitBranch: ["흐름", "분기", "프로세스"],
Workflow: ["워크플로우", "플로우", "프로세스"],
ArrowRightLeft: ["이동", "전환", "교환"],
ArrowRight: ["오른쪽", "다음", "진행"],
ArrowLeft: ["왼쪽", "이전", "뒤로"],
ArrowUp: ["위", "상승", "업"],
ArrowDown: ["아래", "하강", "다운"],
Layers: ["레이어", "계층", "구조"],
PieChart: ["파이차트", "통계", "비율"],
TrendingUp: ["추세", "성장", "상승"],
TrendingDown: ["추세", "하락", "하강"],
AlertTriangle: ["경고", "주의"],
AlertCircle: ["경고", "주의", "원"],
CheckCircle: ["완료", "승인", "확인"],
CheckCircle2: ["완료", "승인", "확인"],
Check: ["확인", "체크"],
Cog: ["톱니바퀴", "설정", "옵션"],
Map: ["지도", "위치", "경로"],
MapPin: ["지도핀", "위치", "장소"],
Printer: ["프린터", "인쇄", "출력"],
UserCog: ["사용자설정", "계정", "프로필"],
UserPlus: ["사용자추가", "회원가입"],
UserCheck: ["사용자확인", "인증"],
Key: ["키", "권한", "인증", "보안"],
Lock: ["잠금", "보안", "비밀번호"],
LockOpen: ["잠금해제", "열기"],
Unlock: ["잠금해제"],
Hammer: ["작업", "공구", "수리"],
Ruler: ["측정", "규격", "사양"],
Scan: ["스캔", "바코드", "QR"],
QrCode: ["QR코드", "큐알"],
ScrollText: ["계약", "문서", "스크롤"],
HandCoins: ["구매", "발주", "거래"],
CircleDollarSign: ["매출", "수익", "원가"],
FileSpreadsheet: ["엑셀", "스프레드시트", "표"],
FilePlus2: ["신규", "추가", "등록"],
FilePlus: ["파일추가", "신규"],
FileCheck2: ["승인", "결재", "확인"],
FileCheck: ["파일확인"],
Zap: ["전기", "에너지", "빠른"],
Gauge: ["게이지", "성능", "속도"],
HardDrive: ["저장", "서버", "디스크"],
Monitor: ["모니터", "화면", "디스플레이"],
Smartphone: ["모바일", "스마트폰", "앱"],
Lightbulb: ["아이디어", "제안", "개선"],
Star: ["별", "즐겨찾기", "중요"],
Heart: ["좋아요", "관심", "찜"],
Bookmark: ["북마크", "저장", "즐겨찾기"],
Flag: ["플래그", "깃발", "표시"],
Award: ["수상", "인증", "포상"],
Trophy: ["트로피", "우승", "성과"],
Target: ["목표", "타겟", "대상"],
Crosshair: ["크로스헤어", "조준", "정확"],
Eye: ["보기", "조회", "미리보기"],
EyeOff: ["숨기기", "비공개"],
Image: ["이미지", "사진", "그림"],
Camera: ["카메라", "사진", "촬영"],
Video: ["비디오", "영상", "동영상"],
Music: ["음악", "오디오", "사운드"],
Mic: ["마이크", "음성", "녹음"],
Phone: ["전화", "연락", "콜"],
PhoneCall: ["통화", "전화"],
MessageSquare: ["메시지", "채팅", "대화"],
MessageCircle: ["메시지", "채팅"],
Send: ["보내기", "전송", "발송"],
Share2: ["공유", "전달"],
Link: ["링크", "연결", "URL"],
ExternalLink: ["외부링크", "새창"],
Download: ["다운로드", "내려받기"],
Upload: ["업로드", "올리기"],
CloudUpload: ["클라우드업로드", "올리기"],
CloudDownload: ["클라우드다운로드", "내려받기"],
Cloud: ["클라우드", "구름"],
Server: ["서버", "시스템"],
Cpu: ["CPU", "프로세서", "처리"],
Wifi: ["와이파이", "네트워크", "무선"],
Activity: ["활동", "모니터링", "심박"],
Thermometer: ["온도", "온도계", "측정"],
Droplets: ["물", "수질", "액체"],
Wind: ["바람", "공기", "환기"],
Sun: ["태양", "밝기", "낮"],
Moon: ["달", "야간", "다크모드"],
Umbrella: ["우산", "보호", "보험"],
Compass: ["나침반", "방향", "가이드"],
Navigation: ["네비게이션", "안내"],
RotateCcw: ["되돌리기", "새로고침", "초기화"],
RefreshCw: ["새로고침", "갱신", "동기화"],
Repeat: ["반복", "되풀이"],
Shuffle: ["셔플", "무작위", "랜덤"],
Filter: ["필터", "거르기", "조건"],
SlidersHorizontal: ["슬라이더", "조정", "필터"],
Maximize2: ["최대화", "전체화면"],
Minimize2: ["최소화", "축소"],
Move: ["이동", "옮기기"],
Copy: ["복사", "복제"],
Scissors: ["가위", "잘라내기"],
Trash2: ["삭제", "쓰레기통", "휴지통"],
Trash: ["삭제", "쓰레기"],
Archive: ["보관", "아카이브", "저장"],
ArchiveRestore: ["복원", "복구"],
Plus: ["추가", "더하기", "플러스"],
Minus: ["빼기", "마이너스", "제거"],
PlusCircle: ["추가", "원형추가"],
MinusCircle: ["제거", "원형제거"],
XCircle: ["닫기", "취소", "제거"],
Info: ["정보", "안내", "도움말"],
HelpCircle: ["도움말", "질문", "안내"],
CircleAlert: ["경고", "주의", "원형경고"],
Ban: ["금지", "차단", "비허용"],
ShieldCheck: ["보안확인", "인증완료"],
ShieldAlert: ["보안경고", "위험"],
LogIn: ["로그인", "접속"],
LogOut: ["로그아웃", "종료"],
Power: ["전원", "켜기/끄기"],
ToggleLeft: ["토글", "스위치", "끄기"],
ToggleRight: ["토글", "스위치", "켜기"],
Percent: ["퍼센트", "비율", "할인"],
Hash: ["해시", "번호", "코드"],
AtSign: ["앳", "이메일", "골뱅이"],
Code: ["코드", "개발", "프로그래밍"],
Terminal: ["터미널", "명령어", "콘솔"],
Table: ["테이블", "표", "데이터"],
Table2: ["테이블", "표"],
Columns: ["컬럼", "열", "항목"],
Rows: ["행", "줄"],
Grid3x3: ["그리드", "격자", "표"],
PanelLeft: ["패널", "사이드바", "왼쪽"],
PanelRight: ["패널", "사이드바", "오른쪽"],
Split: ["분할", "나누기"],
Combine: ["결합", "합치기"],
Network: ["네트워크", "연결망"],
Radio: ["라디오", "옵션"],
CircleDot: ["원형점", "선택"],
SquareCheck: ["체크박스", "선택"],
Square: ["사각형", "상자"],
Circle: ["원", "동그라미"],
Triangle: ["삼각형", "세모"],
Hexagon: ["육각형", "벌집"],
Diamond: ["다이아몬드", "마름모"],
Pen: ["펜", "작성", "편집"],
Pencil: ["연필", "수정", "편집"],
PenLine: ["펜라인", "서명"],
Eraser: ["지우개", "삭제", "초기화"],
Palette: ["팔레트", "색상", "디자인"],
Paintbrush: ["브러시", "페인트", "디자인"],
Figma: ["피그마", "디자인"],
Type: ["타입", "글꼴", "폰트"],
Bold: ["굵게", "볼드"],
Italic: ["기울임", "이탤릭"],
AlignLeft: ["왼쪽정렬"],
AlignCenter: ["가운데정렬"],
AlignRight: ["오른쪽정렬"],
Footprints: ["발자국", "추적", "이력"],
Fingerprint: ["지문", "인증", "보안"],
ScanLine: ["스캔라인", "인식"],
Barcode: ["바코드"],
CreditCard: ["신용카드", "결제", "카드"],
Wallet: ["지갑", "결제", "자금"],
Banknote: ["지폐", "현금", "돈"],
Coins: ["동전", "코인"],
PiggyBank: ["저금통", "저축", "예산"],
Landmark: ["랜드마크", "은행", "기관"],
Store: ["매장", "상점", "가게"],
GraduationCap: ["졸업", "교육", "학습"],
School: ["학교", "교육", "훈련"],
Library: ["도서관", "라이브러리"],
BookMarked: ["북마크", "표시된책"],
Notebook: ["노트북", "공책", "메모"],
NotebookPen: ["노트작성", "메모"],
FileArchive: ["압축파일", "아카이브"],
FileAudio: ["오디오파일", "음악파일"],
FileVideo: ["비디오파일", "영상파일"],
FileImage: ["이미지파일", "사진파일"],
FileCode: ["코드파일", "소스파일"],
FileJson: ["JSON파일", "데이터파일"],
FileCog: ["파일설정", "환경설정"],
FileSearch: ["파일검색", "문서검색"],
FileWarning: ["파일경고", "주의파일"],
FileX: ["파일삭제", "파일제거"],
Files: ["파일들", "다중파일"],
FolderSearch: ["폴더검색"],
FolderCog: ["폴더설정"],
FolderInput: ["입력폴더", "수신"],
FolderOutput: ["출력폴더", "발신"],
FolderSync: ["폴더동기화"],
FolderTree: ["폴더트리", "계층구조"],
Inbox: ["받은편지함", "수신"],
MailOpen: ["메일열기", "읽음"],
MailPlus: ["메일추가", "새메일"],
CalendarCheck: ["일정확인", "예약확인"],
CalendarPlus: ["일정추가", "새일정"],
CalendarX: ["일정취소", "일정삭제"],
Timer: ["타이머", "시간측정"],
Hourglass: ["모래시계", "대기", "로딩"],
AlarmClock: ["알람", "시계"],
Watch: ["시계", "손목시계"],
Rocket: ["로켓", "출시", "배포"],
Plane: ["비행기", "항공", "운송"],
Ship: ["배", "선박", "해운"],
Car: ["자동차", "차량"],
Bus: ["버스", "대중교통"],
Train: ["기차", "열차", "철도"],
Bike: ["자전거", "이동"],
Fuel: ["연료", "주유"],
Construction: ["공사", "건설", "설치"],
HardHat: ["안전모", "건설", "안전"],
Shovel: ["삽", "건설", "시공"],
Drill: ["드릴", "공구"],
Nut: ["너트", "부품", "볼트"],
Plug: ["플러그", "전원", "연결"],
Cable: ["케이블", "선", "연결"],
Battery: ["배터리", "충전"],
BatteryCharging: ["충전중", "배터리"],
Signal: ["신호", "강도"],
Antenna: ["안테나", "수신"],
Bluetooth: ["블루투스", "무선"],
Usb: ["USB", "연결"],
SquareStack: ["스택", "쌓기", "레이어"],
Component: ["컴포넌트", "부품", "구성요소"],
Puzzle: ["퍼즐", "조각", "모듈"],
Blocks: ["블록", "구성요소"],
GitCommit: ["커밋", "변경"],
GitMerge: ["병합", "머지"],
GitPullRequest: ["풀리퀘스트", "요청"],
GitCompare: ["비교", "차이"],
CirclePlay: ["재생", "플레이"],
CirclePause: ["일시정지", "멈춤"],
CircleStop: ["정지", "중지"],
SkipForward: ["다음", "건너뛰기"],
SkipBack: ["이전", "뒤로"],
Volume2: ["볼륨", "소리"],
VolumeX: ["음소거"],
Headphones: ["헤드폰", "오디오"],
Speaker: ["스피커", "소리"],
Projector: ["프로젝터", "발표"],
Presentation: ["프레젠테이션", "발표"],
GanttChart: ["간트차트", "일정관리", "프로젝트"],
KanbanSquare: ["칸반", "보드", "프로젝트"],
ListTodo: ["할일목록", "체크리스트"],
ListChecks: ["체크목록", "확인목록"],
ListFilter: ["필터목록", "조건목록"],
ListTree: ["트리목록", "계층목록"],
StretchHorizontal: ["가로확장"],
StretchVertical: ["세로확장"],
Maximize: ["최대화"],
Minimize: ["최소화"],
Expand: ["확장", "펼치기"],
Shrink: ["축소", "줄이기"],
ZoomIn: ["확대"],
ZoomOut: ["축소"],
Focus: ["포커스", "집중"],
Crosshairs: ["조준", "대상"],
Locate: ["위치찾기", "현재위치"],
LocateFixed: ["위치고정"],
LocateOff: ["위치끄기"],
Spline: ["스플라인", "곡선"],
BrainCircuit: ["AI", "인공지능", "두뇌"],
Brain: ["두뇌", "지능", "생각"],
Bot: ["봇", "로봇", "자동화"],
Sparkles: ["반짝", "AI", "마법"],
Wand2: ["마법봉", "자동", "AI"],
FlaskConical: ["실험", "연구", "시험"],
TestTube: ["시험관", "검사", "테스트"],
Microscope: ["현미경", "분석", "연구"],
Stethoscope: ["청진기", "의료", "진단"],
Syringe: ["주사기", "의료"],
Pill: ["약", "의약품"],
HeartPulse: ["심박", "건강", "의료"],
Dna: ["DNA", "유전", "생명과학"],
Atom: ["원자", "과학", "화학"],
Beaker: ["비커", "실험", "화학"],
Scale: ["저울", "무게", "측정"],
Weight: ["무게", "중량"],
Ratio: ["비율", "비교"],
Calculator: ["계산기", "계산"],
Binary: ["이진수", "코드"],
Regex: ["정규식", "패턴"],
Variable: ["변수", "값"],
FunctionSquare: ["함수", "기능"],
Braces: ["중괄호", "코드"],
Brackets: ["대괄호", "배열"],
Parentheses: ["소괄호", "그룹"],
Tally5: ["집계", "카운트", "합계"],
Sigma: ["시그마", "합계", "총합"],
Infinity: ["무한", "반복"],
Pi: ["파이", "수학"],
Omega: ["오메가", "마지막"],
};
interface IconEntry {
name: string;
component: IconComponent;
keywords: string[];
}
// 모든 Lucide 아이콘을 동적으로 가져오기
const ALL_ICONS: IconEntry[] = (() => {
const entries: IconEntry[] = [];
for (const [name, maybeComponent] of Object.entries(LucideIcons)) {
if (EXCLUDED_EXPORTS.has(name)) continue;
if (!isPascalCase(name)) continue;
// lucide-react 아이콘은 forwardRef + memo로 감싸진 React 컴포넌트 (object)
const comp = maybeComponent as any;
const isReactComponent =
typeof comp === "function" ||
(typeof comp === "object" && comp !== null && comp.$$typeof);
if (!isReactComponent) continue;
const koreanKw = KOREAN_KEYWORDS[name] || [];
entries.push({
name,
component: comp as IconComponent,
keywords: [...koreanKw, name.toLowerCase()],
});
}
return entries.sort((a, b) => a.name.localeCompare(b.name));
})();
export function getIconComponent(iconName: string | null | undefined): IconComponent | null {
if (!iconName) return null;
const entry = ALL_ICONS.find((e) => e.name === iconName);
return entry?.component || null;
}
interface MenuIconPickerProps {
value: string;
onChange: (iconName: string) => void;
label?: string;
}
export const MenuIconPicker: React.FC<MenuIconPickerProps> = ({
value,
onChange,
label = "메뉴 아이콘",
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const [visibleCount, setVisibleCount] = useState(120);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchText("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// 드롭다운 열릴 때 표시 개수 초기화
useEffect(() => {
if (isOpen) setVisibleCount(120);
}, [isOpen]);
// 검색어 변경 시 표시 개수 초기화
useEffect(() => {
setVisibleCount(120);
}, [searchText]);
const filteredIcons = useMemo(() => {
if (!searchText) return ALL_ICONS;
const lower = searchText.toLowerCase();
return ALL_ICONS.filter(
(entry) =>
entry.name.toLowerCase().includes(lower) ||
entry.keywords.some((kw) => kw.includes(lower))
);
}, [searchText]);
// 스크롤 끝에 도달하면 더 로드
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
setVisibleCount((prev) => Math.min(prev + 120, filteredIcons.length));
}
}, [filteredIcons.length]);
const selectedIcon = ALL_ICONS.find((e) => e.name === value);
const SelectedIconComponent = selectedIcon?.component;
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="relative" ref={containerRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="h-10 w-full justify-between text-sm"
>
<div className="flex items-center gap-2">
{SelectedIconComponent ? (
<>
<SelectedIconComponent className="h-4 w-4" />
<span>{selectedIcon?.name}</span>
</>
) : (
<span className="text-muted-foreground"> ()</span>
)}
</div>
{value ? (
<X
className="h-4 w-4 opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onChange("");
setIsOpen(false);
}}
/>
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</Button>
{isOpen && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-lg">
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="아이콘 검색 (한글/영문)..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 pl-8 text-sm"
onClick={(e) => e.stopPropagation()}
autoFocus
/>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="max-h-72 overflow-y-auto p-2"
>
{!searchText && (
<p className="text-muted-foreground mb-2 text-center text-xs">
{ALL_ICONS.length}
</p>
)}
<div className="grid grid-cols-6 gap-1">
{filteredIcons.slice(0, visibleCount).map((entry) => {
const IconComp = entry.component;
return (
<button
key={entry.name}
type="button"
title={entry.name}
onClick={() => {
onChange(entry.name);
setIsOpen(false);
setSearchText("");
}}
className={cn(
"flex flex-col items-center justify-center rounded-md p-2 transition-colors hover:bg-accent",
value === entry.name && "bg-primary/10 ring-primary ring-1"
)}
>
<IconComp className="h-5 w-5" />
<span className="mt-1 max-w-full truncate text-[9px] leading-tight">{entry.name}</span>
</button>
);
})}
</div>
{filteredIcons.length === 0 && (
<p className="text-muted-foreground py-4 text-center text-sm">
.
</p>
)}
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
for (const row of filteredData) { for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
const row = filteredData[rowIdx];
try { try {
let dataToSave = { ...row }; let dataToSave = { ...row };
let shouldSkip = false; let shouldSkip = false;
@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) { if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key); existingRow = existingDataMap.get(key);
// 중복 발견 - 전역 설정에 따라 처리
if (duplicateAction === "skip") { if (duplicateAction === "skip") {
shouldSkip = true; shouldSkip = true;
skipCount++; skipCount++;
console.log(`⏭️ 중복으로 건너뛰기: ${key}`); console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
} else { } else {
shouldUpdate = true; shouldUpdate = true;
console.log(`🔄 중복으로 덮어쓰기: ${key}`); console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
} }
} else {
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
} }
} }
@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName]; const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
tableName, tableName,
data: dataToSave, data: dataToSave,
}; };
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
const result = await DynamicFormApi.updateFormData(existingRow.id, formData); const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
if (result.success) { if (result.success) {
overwriteCount++; overwriteCount++;
successCount++; successCount++;
} else { } else {
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
failCount++; failCount++;
} }
} else if (uploadMode === "insert") { } else if (uploadMode === "insert" || uploadMode === "upsert") {
// 신규 등록 // 신규 등록 (insert, upsert 모드)
const formData = { screenId: 0, tableName, data: dataToSave }; const formData = { screenId: 0, tableName, data: dataToSave };
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
const result = await DynamicFormApi.saveFormData(formData); const result = await DynamicFormApi.saveFormData(formData);
if (result.success) { if (result.success) {
successCount++; successCount++;
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
} else { } else {
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
failCount++; failCount++;
} }
} else if (uploadMode === "update") {
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
skipCount++;
} }
} catch (error) { } catch (error: any) {
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
failCount++; failCount++;
} }
} }
@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
if (successCount > 0 || skipCount > 0) { if (successCount > 0 || skipCount > 0) {
// 상세 결과 메시지 생성
let message = ""; let message = "";
if (successCount > 0) { if (successCount > 0) {
message += `${successCount}개 행 업로드`; message += `${successCount}개 행 업로드`;
@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
message += `중복 건너뛰기 ${skipCount}`; message += `중복 건너뛰기 ${skipCount}`;
} }
if (failCount > 0) { if (failCount > 0) {
message += ` (실패: ${failCount})`; message += `, 실패 ${failCount}`;
} }
toast.success(message); if (failCount > 0 && successCount === 0) {
toast.warning(message);
} else {
toast.success(message);
}
// 매핑 템플릿 저장 // 매핑 템플릿 저장
await saveMappingTemplateInternal(); await saveMappingTemplateInternal();
onSuccess?.(); if (successCount > 0 || overwriteCount > 0) {
onSuccess?.();
}
} else if (failCount > 0) {
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
} else { } else {
toast.error("업로드에 실패했습니다."); toast.error("업로드에 실패했습니다.");
} }

View File

@ -18,6 +18,7 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
@ -548,11 +549,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용) setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
} else { } else {
toast.error("데이터를 불러올 수 없습니다."); toast.error("수정할 데이터를 불러올 수 없습니다.", {
description: "해당 항목이 삭제되었거나 접근 권한이 없을 수 있습니다.",
});
} }
} catch (error) { } catch (error) {
console.error("수정 데이터 조회 오류:", error); console.error("수정 데이터 조회 오류:", error);
toast.error("데이터를 불러오는 중 오류가 발생했습니다."); showErrorToast("수정 데이터 조회에 실패했습니다", error, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
});
} }
} }
} }
@ -604,7 +609,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} }
} catch (error) { } catch (error) {
console.error("화면 데이터 로딩 오류:", error); console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다."); showErrorToast("화면 데이터를 불러오는 데 실패했습니다", error, {
guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.",
});
handleClose(); handleClose();
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -21,6 +21,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react"; import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -61,7 +62,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
} }
} catch (error) { } catch (error) {
console.error("플로우 목록 조회 실패", error); console.error("플로우 목록 조회 실패", error);
toast.error("플로우 목록을 불러오는데 실패했습니다."); showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -107,7 +108,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
} }
} catch (error) { } catch (error) {
console.error("플로우 복사 실패:", error); console.error("플로우 복사 실패:", error);
toast.error("플로우 복사에 실패했습니다."); showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -129,7 +130,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) {
} }
} catch (error) { } catch (error) {
console.error("플로우 삭제 실패:", error); console.error("플로우 삭제 실패:", error);
toast.error("플로우 삭제에 실패했습니다."); showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
setShowDeleteModal(false); setShowDeleteModal(false);

View File

@ -24,6 +24,7 @@ import {
Timer, Timer,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
// 타입 import // 타입 import
import { import {
@ -144,7 +145,7 @@ const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
toast.success("API 테스트가 성공했습니다!"); toast.success("API 테스트가 성공했습니다!");
setActiveTab("response"); setActiveTab("response");
} else { } else {
toast.error("API 호출이 실패했습니다."); showErrorToast("API 호출이 실패했습니다", null, { guidance: "URL과 요청 설정을 확인해 주세요." });
setActiveTab("response"); setActiveTab("response");
} }
} else { } else {
@ -156,7 +157,7 @@ const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
}; };
setTestResult(errorResult); setTestResult(errorResult);
onTestResult(errorResult); onTestResult(errorResult);
toast.error(response.error || "테스트 실행 중 오류가 발생했습니다."); showErrorToast("API 테스트 실행에 실패했습니다", response.error, { guidance: "URL과 요청 설정을 확인해 주세요." });
} }
} catch (error) { } catch (error) {
const errorResult: ApiTestResult = { const errorResult: ApiTestResult = {
@ -167,7 +168,7 @@ const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
}; };
setTestResult(errorResult); setTestResult(errorResult);
onTestResult(errorResult); onTestResult(errorResult);
toast.error("테스트 실행 중 오류가 발생했습니다."); showErrorToast("API 테스트 실행에 실패했습니다", error, { guidance: "네트워크 연결과 URL을 확인해 주세요." });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -179,7 +180,7 @@ const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success("클립보드에 복사되었습니다."); toast.success("클립보드에 복사되었습니다.");
} catch (error) { } catch (error) {
toast.error("복사에 실패했습니다."); showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
} }
}, []); }, []);

View File

@ -32,6 +32,7 @@ import { LogNode } from "./nodes/LogNode";
import { EmailActionNode } from "./nodes/EmailActionNode"; import { EmailActionNode } from "./nodes/EmailActionNode";
import { ScriptActionNode } from "./nodes/ScriptActionNode"; import { ScriptActionNode } from "./nodes/ScriptActionNode";
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode"; import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
import { validateFlow } from "@/lib/utils/flowValidation"; import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation";
@ -55,6 +56,7 @@ const nodeTypes = {
emailAction: EmailActionNode, emailAction: EmailActionNode,
scriptAction: ScriptActionNode, scriptAction: ScriptActionNode,
httpRequestAction: HttpRequestActionNode, httpRequestAction: HttpRequestActionNode,
procedureCallAction: ProcedureCallActionNode,
// 유틸리티 // 유틸리티
comment: CommentNode, comment: CommentNode,
log: LogNode, log: LogNode,

View File

@ -0,0 +1,121 @@
"use client";
/**
* /
* DB의 /
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Database, Workflow } from "lucide-react";
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
export const ProcedureCallActionNode = memo(
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
const hasProcedure = !!data.procedureName;
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-violet-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
<Workflow 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">
{/* DB 소스 */}
<div className="flex items-center gap-2">
<Database className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-600">
{data.dbSource === "external" ? (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
{data.connectionName || "외부 DB"}
</span>
) : (
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">
DB
</span>
)}
</span>
<span
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
data.callType === "function"
? "bg-cyan-100 text-cyan-700"
: "bg-violet-100 text-violet-700"
}`}
>
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
</span>
</div>
{/* 프로시저명 */}
<div className="flex items-center gap-2 text-xs">
<Workflow className="h-3 w-3 text-gray-400" />
{hasProcedure ? (
<span className="font-mono text-green-600 truncate">
{data.procedureSchema && data.procedureSchema !== "public"
? `${data.procedureSchema}.`
: ""}
{data.procedureName}()
</span>
) : (
<span className="text-orange-500"> </span>
)}
</div>
{/* 파라미터 수 */}
{hasProcedure && inParams.length > 0 && (
<div className="text-xs text-gray-500">
: {inParams.length}
</div>
)}
{/* 반환 필드 */}
{hasProcedure && outParams.length > 0 && (
<div className="mt-1 space-y-1 border-t border-gray-100 pt-1">
<div className="text-[10px] font-medium text-green-600">
:
</div>
{outParams.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded bg-green-50 px-2 py-0.5 text-[10px]"
>
<span className="font-mono text-green-700">{p.name}</span>
<span className="text-gray-400">{p.dataType}</span>
</div>
))}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
/>
</div>
);
}
);
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";

View File

@ -23,6 +23,7 @@ import { LogProperties } from "./properties/LogProperties";
import { EmailActionProperties } from "./properties/EmailActionProperties"; import { EmailActionProperties } from "./properties/EmailActionProperties";
import { ScriptActionProperties } from "./properties/ScriptActionProperties"; import { ScriptActionProperties } from "./properties/ScriptActionProperties";
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties"; import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
import { ProcedureCallActionProperties } from "./properties/ProcedureCallActionProperties";
import type { NodeType } from "@/types/node-editor"; import type { NodeType } from "@/types/node-editor";
export function PropertiesPanel() { export function PropertiesPanel() {
@ -147,6 +148,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "httpRequestAction": case "httpRequestAction":
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />; return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
case "procedureCallAction":
return <ProcedureCallActionProperties nodeId={node.id} data={node.data} />;
default: default:
return ( return (
<div className="p-4"> <div className="p-4">
@ -185,6 +189,7 @@ function getNodeTypeLabel(type: NodeType): string {
emailAction: "메일 발송", emailAction: "메일 발송",
scriptAction: "스크립트 실행", scriptAction: "스크립트 실행",
httpRequestAction: "HTTP 요청", httpRequestAction: "HTTP 요청",
procedureCallAction: "프로시저 호출",
comment: "주석", comment: "주석",
log: "로그", log: "로그",
}; };

View File

@ -20,6 +20,7 @@ import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getNumberingRules } from "@/lib/api/numberingRule"; import { getNumberingRules } from "@/lib/api/numberingRule";
import type { NumberingRuleConfig } from "@/types/numbering-rule"; import type { NumberingRuleConfig } from "@/types/numbering-rule";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { InsertActionNodeData } from "@/types/node-editor"; import type { InsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@ -171,10 +172,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
// 프로시저 노드 정보를 수집하여 비동기 파라미터 조회에 사용
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
sourcePath: string[];
}> = [];
const getAllSourceFields = ( const getAllSourceFields = (
targetNodeId: string, targetNodeId: string,
visitedNodes = new Set<string>(), visitedNodes = new Set<string>(),
sourcePath: string[] = [], // 🔥 소스 경로 추적 sourcePath: string[] = [],
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => { ): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
if (visitedNodes.has(targetNodeId)) { if (visitedNodes.has(targetNodeId)) {
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`); console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
@ -366,7 +376,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI; foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
} }
} }
// 5⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 // 5⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터(반환 필드) 추가
else if (node.type === "procedureCallAction") {
console.log("✅ 프로시저 호출 노드 발견");
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({
name: p.name,
label: `${p.name} (프로시저 반환)`,
sourcePath: currentPath,
});
}
}
}
// OUT 파라미터가 저장되어 있지 않으면 API로 동적 조회 예약
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
sourcePath: currentPath,
});
}
fields.push({
name: "_procedureReturn",
label: "프로시저 반환값",
sourcePath: currentPath,
});
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else { else {
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
@ -386,31 +437,66 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log(` - 총 필드 수: ${result.fields.length}`); console.log(` - 총 필드 수: ${result.fields.length}`);
console.log(` - REST API 포함: ${result.hasRestAPI}`); console.log(` - REST API 포함: ${result.hasRestAPI}`);
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시 const applyFields = (allFields: typeof result.fields) => {
const fieldMap = new Map<string, (typeof result.fields)[number]>(); const fieldMap = new Map<string, (typeof result.fields)[number]>();
const duplicateFields = new Set<string>(); const duplicateFields = new Set<string>();
result.fields.forEach((field) => { allFields.forEach((field) => {
const key = `${field.name}`; const key = `${field.name}`;
if (fieldMap.has(key)) { if (fieldMap.has(key)) {
duplicateFields.add(field.name); duplicateFields.add(field.name);
}
fieldMap.set(key, field);
});
if (duplicateFields.size > 0) {
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
} }
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
fieldMap.set(key, field);
});
if (duplicateFields.size > 0) { const uniqueFields = Array.from(fieldMap.values());
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); setSourceFields(uniqueFields);
console.warn(" → 마지막으로 발견된 필드만 표시됩니다."); setHasRestAPISource(result.hasRestAPI);
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!"); console.log("✅ 최종 소스 필드 목록:", uniqueFields);
};
// 프로시저 노드에 OUT 파라미터가 저장되지 않은 경우, API로 동적 조회
if (procedureNodes.length > 0) {
console.log(`🔄 프로시저 ${procedureNodes.length}개의 반환 필드를 API로 조회`);
applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(
pn.procedureName,
pn.dbSource,
pn.connectionId,
pn.schema
);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({
name: p.name,
label: `${p.name} (프로시저 반환)`,
sourcePath: pn.sourcePath,
}));
}
} catch (e) {
console.error("프로시저 파라미터 조회 실패:", e);
}
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) {
console.log(`✅ 프로시저 반환 필드 ${extraFields.length}개 추가 발견`);
applyFields([...result.fields, ...extraFields]);
}
});
} else {
applyFields(result.fields);
} }
const uniqueFields = Array.from(fieldMap.values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
console.log("✅ REST API 소스 연결:", result.hasRestAPI);
}, [nodeId, nodes, edges]); }, [nodeId, nodes, edges]);
/** /**

View File

@ -0,0 +1,641 @@
"use client";
/**
* /
*/
import { useEffect, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Database, Workflow, RefreshCw, Loader2 } from "lucide-react";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import {
getFlowProcedures,
getFlowProcedureParameters,
} from "@/lib/api/flow";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb";
interface ExternalConnection {
id: number;
connection_name: string;
db_type: string;
}
interface ProcedureCallActionPropertiesProps {
nodeId: string;
data: ProcedureCallActionNodeData;
}
export function ProcedureCallActionProperties({
nodeId,
data,
}: ProcedureCallActionPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
const [displayName, setDisplayName] = useState(
data.displayName || "프로시저 호출"
);
const [dbSource, setDbSource] = useState<"internal" | "external">(
data.dbSource || "internal"
);
const [connectionId, setConnectionId] = useState<number | undefined>(
data.connectionId
);
const [procedureName, setProcedureName] = useState(
data.procedureName || ""
);
const [procedureSchema, setProcedureSchema] = useState(
data.procedureSchema || "public"
);
const [callType, setCallType] = useState<"procedure" | "function">(
data.callType || "function"
);
const [parameters, setParameters] = useState(data.parameters || []);
const [connections, setConnections] = useState<ExternalConnection[]>([]);
const [procedures, setProcedures] = useState<ProcedureListItem[]>([]);
const [loadingProcedures, setLoadingProcedures] = useState(false);
const [loadingParams, setLoadingParams] = useState(false);
const [sourceFields, setSourceFields] = useState<
Array<{ name: string; label?: string }>
>([]);
// 이전 노드에서 소스 필드 목록 수집 (재귀)
useEffect(() => {
const getUpstreamFields = (
targetId: string,
visited = new Set<string>()
): Array<{ name: string; label?: string }> => {
if (visited.has(targetId)) return [];
visited.add(targetId);
const inEdges = edges.filter((e) => e.target === targetId);
const parentNodes = nodes.filter((n) =>
inEdges.some((e) => e.source === n.id)
);
const fields: Array<{ name: string; label?: string }> = [];
for (const pNode of parentNodes) {
if (
pNode.type === "tableSource" ||
pNode.type === "externalDBSource"
) {
const nodeFields =
(pNode.data as any).fields ||
(pNode.data as any).outputFields ||
[];
if (Array.isArray(nodeFields)) {
for (const f of nodeFields) {
const name =
typeof f === "string"
? f
: f.name || f.columnName || f.field;
if (name) {
fields.push({
name,
label: f.label || f.columnLabel || name,
});
}
}
}
} else if (pNode.type === "dataTransform") {
const upper = getUpstreamFields(pNode.id, visited);
fields.push(...upper);
const transforms = (pNode.data as any).transformations;
if (Array.isArray(transforms)) {
for (const t of transforms) {
if (t.targetField) {
fields.push({
name: t.targetField,
label: t.targetFieldLabel || t.targetField,
});
}
}
}
} else if (pNode.type === "formulaTransform") {
const upper = getUpstreamFields(pNode.id, visited);
fields.push(...upper);
const transforms = (pNode.data as any).transformations;
if (Array.isArray(transforms)) {
for (const t of transforms) {
if (t.outputField) {
fields.push({
name: t.outputField,
label: t.outputFieldLabel || t.outputField,
});
}
}
}
} else {
fields.push(...getUpstreamFields(pNode.id, visited));
}
}
return fields;
};
const collected = getUpstreamFields(nodeId);
const unique = Array.from(
new Map(collected.map((f) => [f.name, f])).values()
);
setSourceFields(unique);
}, [nodeId, nodes, edges]);
useEffect(() => {
setDisplayName(data.displayName || "프로시저 호출");
setDbSource(data.dbSource || "internal");
setConnectionId(data.connectionId);
setProcedureName(data.procedureName || "");
setProcedureSchema(data.procedureSchema || "public");
setCallType(data.callType || "function");
setParameters(data.parameters || []);
}, [data]);
// 외부 DB 연결 목록 조회
useEffect(() => {
if (dbSource === "external") {
ExternalDbConnectionAPI.getConnections({ is_active: "true" })
.then((list) =>
setConnections(
list.map((c: any) => ({
id: c.id,
connection_name: c.connection_name,
db_type: c.db_type,
}))
)
)
.catch(console.error);
}
}, [dbSource]);
const updateNodeData = useCallback(
(updates: Partial<ProcedureCallActionNodeData>) => {
updateNode(nodeId, { ...data, ...updates });
},
[nodeId, data, updateNode]
);
// 프로시저 목록 조회
const fetchProcedures = useCallback(async () => {
if (dbSource === "external" && !connectionId) return;
setLoadingProcedures(true);
try {
const res = await getFlowProcedures(
dbSource,
connectionId,
procedureSchema || undefined
);
if (res.success && res.data) {
setProcedures(res.data);
}
} catch (e) {
console.error("프로시저 목록 조회 실패:", e);
} finally {
setLoadingProcedures(false);
}
}, [dbSource, connectionId, procedureSchema]);
// dbSource/connectionId 변경 시 프로시저 목록 자동 조회
useEffect(() => {
if (dbSource === "internal" || (dbSource === "external" && connectionId)) {
fetchProcedures();
}
}, [dbSource, connectionId, fetchProcedures]);
// 프로시저 선택 시 파라미터 조회
const handleProcedureSelect = useCallback(
async (name: string) => {
setProcedureName(name);
const selected = procedures.find((p) => p.name === name);
const newCallType =
selected?.type === "PROCEDURE" ? "procedure" : "function";
setCallType(newCallType);
updateNodeData({
procedureName: name,
callType: newCallType,
procedureSchema,
});
setLoadingParams(true);
try {
const res = await getFlowProcedureParameters(
name,
dbSource,
connectionId,
procedureSchema || undefined
);
if (res.success && res.data) {
const newParams = res.data.map((p: ProcedureParameterInfo) => ({
name: p.name,
dataType: p.dataType,
mode: p.mode,
source: "record_field" as const,
field: "",
value: "",
}));
setParameters(newParams);
updateNodeData({
procedureName: name,
callType: newCallType,
procedureSchema,
parameters: newParams,
});
}
} catch (e) {
console.error("파라미터 조회 실패:", e);
} finally {
setLoadingParams(false);
}
},
[dbSource, connectionId, procedureSchema, procedures, updateNodeData]
);
const handleParamChange = (
index: number,
field: string,
value: string
) => {
const newParams = [...parameters];
(newParams[index] as any)[field] = value;
setParameters(newParams);
updateNodeData({ parameters: newParams });
};
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) => {
setDisplayName(e.target.value);
updateNodeData({ displayName: e.target.value });
}}
placeholder="프로시저 호출"
className="h-8 text-sm"
/>
</div>
{/* DB 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium">DB </Label>
<Select
value={dbSource}
onValueChange={(v: "internal" | "external") => {
setDbSource(v);
setConnectionId(undefined);
setProcedureName("");
setParameters([]);
setProcedures([]);
updateNodeData({
dbSource: v,
connectionId: undefined,
connectionName: undefined,
procedureName: "",
parameters: [],
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB (PostgreSQL)</SelectItem>
<SelectItem value="external"> DB</SelectItem>
</SelectContent>
</Select>
</div>
{/* 외부 DB 연결 선택 */}
{dbSource === "external" && (
<div className="space-y-2">
<Label className="text-xs font-medium"> DB </Label>
<Select
value={connectionId?.toString() || ""}
onValueChange={(v) => {
const id = parseInt(v);
setConnectionId(id);
setProcedureName("");
setParameters([]);
const conn = connections.find((c) => c.id === id);
updateNodeData({
connectionId: id,
connectionName: conn?.connection_name,
procedureName: "",
parameters: [],
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>
{c.connection_name} ({c.db_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 스키마 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-2">
<Input
value={procedureSchema}
onChange={(e) => setProcedureSchema(e.target.value)}
onBlur={() => {
updateNodeData({ procedureSchema });
fetchProcedures();
}}
placeholder="public"
className="h-8 text-sm"
/>
<Button
variant="outline"
size="sm"
className="h-8 w-8 shrink-0 p-0"
onClick={fetchProcedures}
disabled={loadingProcedures}
>
{loadingProcedures ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* 프로시저 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium">/ </Label>
{loadingProcedures ? (
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<Select
value={procedureName}
onValueChange={handleProcedureSelect}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="프로시저 선택" />
</SelectTrigger>
<SelectContent>
{procedures.map((p) => (
<SelectItem key={`${p.schema}.${p.name}`} value={p.name}>
<div className="flex items-center gap-2">
<span
className={`rounded px-1 py-0.5 text-[10px] font-medium ${
p.type === "FUNCTION"
? "bg-cyan-100 text-cyan-700"
: "bg-violet-100 text-violet-700"
}`}
>
{p.type === "FUNCTION" ? "FN" : "SP"}
</span>
<span className="font-mono text-xs">{p.name}</span>
</div>
</SelectItem>
))}
{procedures.length === 0 && (
<SelectItem value="" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{/* 호출 타입 */}
{procedureName && (
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={callType}
onValueChange={(v: "procedure" | "function") => {
setCallType(v);
updateNodeData({ callType: v });
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="function">SELECT ()</SelectItem>
<SelectItem value="procedure">CALL ()</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 파라미터 매핑 */}
{procedureName && parameters.length > 0 && (
<div className="space-y-3">
{loadingParams ? (
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<>
{/* IN 파라미터 */}
{parameters.filter((p) => p.mode === "IN" || p.mode === "INOUT")
.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-1.5 text-xs font-medium">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
IN
</span>
</Label>
<div className="space-y-2">
{parameters.map((param, idx) => {
if (param.mode !== "IN" && param.mode !== "INOUT")
return null;
return (
<Card key={idx} className="bg-gray-50">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-medium">
{param.name}
</span>
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px]">
{param.dataType}
</span>
</div>
<Select
value={param.source}
onValueChange={(v) =>
handleParamChange(idx, "source", v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="record_field">
</SelectItem>
<SelectItem value="static"></SelectItem>
<SelectItem value="step_variable">
</SelectItem>
</SelectContent>
</Select>
{param.source === "record_field" &&
(sourceFields.length > 0 ? (
<Select
value={param.field || ""}
onValueChange={(v) =>
handleParamChange(idx, "field", v)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((f) => (
<SelectItem
key={f.name}
value={f.name}
>
<span className="font-mono text-xs">
{f.name}
</span>
{f.label && f.label !== f.name && (
<span className="ml-1 text-[10px] text-gray-400">
({f.label})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={param.field || ""}
onChange={(e) =>
handleParamChange(
idx,
"field",
e.target.value
)
}
placeholder="컬럼명 (이전 노드를 먼저 연결하세요)"
className="h-7 text-xs"
/>
))}
{param.source === "static" && (
<Input
value={param.value || ""}
onChange={(e) =>
handleParamChange(
idx,
"value",
e.target.value
)
}
placeholder="고정값 입력"
className="h-7 text-xs"
/>
)}
{param.source === "step_variable" && (
<Input
value={param.field || ""}
onChange={(e) =>
handleParamChange(
idx,
"field",
e.target.value
)
}
placeholder="변수명"
className="h-7 text-xs"
/>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* OUT 파라미터 (반환 필드) */}
{parameters.filter((p) => p.mode === "OUT" || p.mode === "INOUT")
.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-1.5 text-xs font-medium">
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
OUT
</span>
<span className="text-[10px] font-normal text-gray-400">
( )
</span>
</Label>
<div className="rounded-md border border-green-200 bg-green-50 p-2">
<div className="space-y-1">
{parameters
.filter(
(p) => p.mode === "OUT" || p.mode === "INOUT"
)
.map((param, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded bg-white px-2 py-1.5"
>
<span className="font-mono text-xs font-medium text-green-700">
{param.name}
</span>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
{param.dataType}
</span>
</div>
))}
</div>
</div>
</div>
)}
</>
)}
</div>
)}
{/* 안내 메시지 */}
<Card className="bg-violet-50">
<CardContent className="p-3 text-xs text-violet-700">
<div className="mb-1 flex items-center gap-1 font-medium">
<Workflow className="h-3 w-3" />
</div>
<p>
. .
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { UpdateActionNodeData } from "@/types/node-editor"; import type { UpdateActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@ -165,6 +166,13 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
}> = [];
const getAllSourceFields = ( const getAllSourceFields = (
targetNodeId: string, targetNodeId: string,
visitedNodes = new Set<string>(), visitedNodes = new Set<string>(),
@ -310,7 +318,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI; foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
} }
} }
// 5⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 // 5⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
else if (node.type === "procedureCallAction") {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
}
}
}
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
});
}
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else { else {
const upperResult = getAllSourceFields(node.id, visitedNodes); const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields); fields.push(...upperResult.fields);
@ -323,11 +357,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const result = getAllSourceFields(nodeId); const result = getAllSourceFields(nodeId);
// 중복 제거 const applyFields = (allFields: typeof result.fields) => {
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
};
setSourceFields(uniqueFields); if (procedureNodes.length > 0) {
setHasRestAPISource(result.hasRestAPI); applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
}
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
});
} else {
applyFields(result.fields);
}
}, [nodeId, nodes, edges]); }, [nodeId, nodes, edges]);
const loadTables = async () => { const loadTables = async () => {

View File

@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getFlowProcedureParameters } from "@/lib/api/flow";
import type { UpsertActionNodeData } from "@/types/node-editor"; import type { UpsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@ -148,6 +149,13 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => { useEffect(() => {
const procedureNodes: Array<{
procedureName: string;
dbSource: "internal" | "external";
connectionId?: number;
schema?: string;
}> = [];
const getAllSourceFields = ( const getAllSourceFields = (
targetNodeId: string, targetNodeId: string,
visitedNodes = new Set<string>(), visitedNodes = new Set<string>(),
@ -293,7 +301,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
foundRestAPI = foundRestAPI || upperResult.hasRestAPI; foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
} }
} }
// 5⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 // 5⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
else if (node.type === "procedureCallAction") {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
const nodeData = node.data as any;
const procParams = nodeData.parameters;
let hasOutParams = false;
if (Array.isArray(procParams)) {
for (const p of procParams) {
if (p.mode === "OUT" || p.mode === "INOUT") {
hasOutParams = true;
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
}
}
}
if (!hasOutParams && nodeData.procedureName) {
procedureNodes.push({
procedureName: nodeData.procedureName,
dbSource: nodeData.dbSource || "internal",
connectionId: nodeData.connectionId,
schema: nodeData.procedureSchema || "public",
});
}
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
}
// 6⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else { else {
const upperResult = getAllSourceFields(node.id, visitedNodes); const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields); fields.push(...upperResult.fields);
@ -306,11 +340,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const result = getAllSourceFields(nodeId); const result = getAllSourceFields(nodeId);
// 중복 제거 const applyFields = (allFields: typeof result.fields) => {
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
};
setSourceFields(uniqueFields); if (procedureNodes.length > 0) {
setHasRestAPISource(result.hasRestAPI); applyFields(result.fields);
Promise.all(
procedureNodes.map(async (pn) => {
try {
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
if (res.success && res.data) {
return res.data
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
}
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
return [];
})
).then((extraFieldArrays) => {
const extraFields = extraFieldArrays.flat();
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
});
} else {
applyFields(result.fields);
}
}, [nodeId, nodes, edges]); }, [nodeId, nodes, edges]);
// 🔥 외부 커넥션 로딩 함수 // 🔥 외부 커넥션 로딩 함수

View File

@ -132,6 +132,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "external", category: "external",
color: "#06B6D4", // 시안 color: "#06B6D4", // 시안
}, },
{
type: "procedureCallAction",
label: "프로시저 호출",
icon: "",
description: "DB 프로시저/함수를 호출합니다",
category: "external",
color: "#8B5CF6", // 보라색
},
// ======================================================================== // ========================================================================
// 유틸리티 // 유틸리티

View File

@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; import { Loader2, AlertCircle, ArrowRight } from "lucide-react";
import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow"; import { getStepDataList, moveBatchData, getFlowConnections } from "@/lib/api/flow";
import { toast } from "sonner"; import { toast } from "sonner";
interface FlowDataListModalProps { interface FlowDataListModalProps {
@ -102,15 +102,28 @@ export function FlowDataListModal({
try { try {
setMovingData(true); setMovingData(true);
// 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) // 다음 스텝 결정 (연결 정보에서 조회)
const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); const connResponse = await getFlowConnections(flowId);
if (!connResponse.success || !connResponse.data) {
throw new Error("플로우 연결 정보를 가져올 수 없습니다");
}
const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId);
if (!nextConn) {
throw new Error("다음 단계가 연결되어 있지 않습니다");
}
// 데이터 이동 API 호출 // 선택된 행의 ID 추출
for (const dataId of selectedDataIds) { const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id));
const response = await moveDataToNextStep(flowId, stepId, dataId);
if (!response.success) { // 배치 이동 API 호출
throw new Error(`데이터 이동 실패: ${response.message}`); const response = await moveBatchData({
} flowId,
fromStepId: stepId,
toStepId: nextConn.toStepId,
dataIds: selectedDataIds,
});
if (!response.success) {
throw new Error(`데이터 이동 실패: ${response.error || "알 수 없는 오류"}`);
} }
toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`); toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);

View File

@ -63,6 +63,11 @@ export const FlowNodeComponent = memo(({ data }: NodeProps<FlowNodeData>) => {
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{data.stepOrder} {data.stepOrder}
</Badge> </Badge>
{data.integrationType === "procedure" && (
<Badge variant="secondary" className="text-[10px] font-mono">
SP
</Badge>
)}
</div> </div>
<div className="text-foreground mb-2 text-sm font-semibold">{data.label}</div> <div className="text-foreground mb-2 text-sm font-semibold">{data.label}</div>
@ -75,6 +80,13 @@ export const FlowNodeComponent = memo(({ data }: NodeProps<FlowNodeData>) => {
</div> </div>
)} )}
{/* 프로시저 정보 */}
{data.integrationType === "procedure" && data.procedureName && (
<div className="bg-muted text-muted-foreground mb-2 flex items-center gap-1 rounded-md px-2 py-1 text-xs">
<span className="font-mono truncate">{data.procedureName}()</span>
</div>
)}
{/* 데이터 건수 */} {/* 데이터 건수 */}
{data.count !== undefined && ( {data.count !== undefined && (
<Badge variant="secondary" className="mb-2 text-xs"> <Badge variant="secondary" className="mb-2 text-xs">

View File

@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow"; import { updateFlowStep, deleteFlowStep, getFlowProcedures, getFlowProcedureParameters } from "@/lib/api/flow";
import { FlowStep } from "@/types/flow"; import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder"; import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
@ -23,6 +23,10 @@ import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import { import {
FlowExternalDbConnection, FlowExternalDbConnection,
FlowExternalDbIntegrationConfig, FlowExternalDbIntegrationConfig,
FlowProcedureConfig,
FlowProcedureParam,
ProcedureListItem,
ProcedureParameterInfo,
INTEGRATION_TYPE_OPTIONS, INTEGRATION_TYPE_OPTIONS,
OPERATION_OPTIONS, OPERATION_OPTIONS,
} from "@/types/flowExternalDb"; } from "@/types/flowExternalDb";
@ -118,6 +122,13 @@ export function FlowStepPanel({
const [availableColumns, setAvailableColumns] = useState<string[]>([]); const [availableColumns, setAvailableColumns] = useState<string[]>([]);
const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false); const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false);
// 프로시저 관련 상태
const [procedureList, setProcedureList] = useState<ProcedureListItem[]>([]);
const [loadingProcedures, setLoadingProcedures] = useState(false);
const [procedureParams, setProcedureParams] = useState<ProcedureParameterInfo[]>([]);
const [loadingProcedureParams, setLoadingProcedureParams] = useState(false);
const [openProcedureCombobox, setOpenProcedureCombobox] = useState(false);
// 테이블 목록 조회 // 테이블 목록 조회
useEffect(() => { useEffect(() => {
const loadTables = async () => { const loadTables = async () => {
@ -943,7 +954,7 @@ export function FlowStepPanel({
<SelectItem <SelectItem
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"} disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "procedure" && opt.value !== "rest_api"}
> >
{opt.label} {opt.label}
</SelectItem> </SelectItem>
@ -1262,6 +1273,370 @@ export function FlowStepPanel({
)} )}
</div> </div>
)} )}
{/* 프로시저/함수 호출 설정 */}
{formData.integrationType === "procedure" && (
<div className="space-y-4 rounded-lg border p-4">
{/* DB 소스 선택 */}
<div>
<Label>DB </Label>
<Select
value={
(formData.integrationConfig as FlowProcedureConfig)?.dbSource || "internal"
}
onValueChange={async (value: "internal" | "external") => {
const newConfig: FlowProcedureConfig = {
type: "procedure",
dbSource: value,
connectionId: undefined,
procedureName: "",
procedureSchema: "public",
callType: "function",
parameters: [],
};
setFormData({ ...formData, integrationConfig: newConfig });
setProcedureList([]);
setProcedureParams([]);
if (value === "internal") {
setLoadingProcedures(true);
try {
const res = await getFlowProcedures("internal");
if (res.success && res.data) setProcedureList(res.data);
} catch (e) {
console.error("프로시저 목록 조회 실패:", e);
} finally {
setLoadingProcedures(false);
}
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB</SelectItem>
<SelectItem value="external"> DB</SelectItem>
</SelectContent>
</Select>
</div>
{/* 외부 DB 연결 선택 */}
{(formData.integrationConfig as FlowProcedureConfig)?.dbSource === "external" && (
<div>
<Label> DB </Label>
{externalConnections.length === 0 ? (
<div className="rounded-md bg-yellow-50 p-3">
<p className="text-sm text-yellow-900">
DB . DB .
</p>
</div>
) : (
<Select
value={
(formData.integrationConfig as FlowProcedureConfig)?.connectionId?.toString() || ""
}
onValueChange={async (value) => {
const connId = parseInt(value);
const newConfig: FlowProcedureConfig = {
...(formData.integrationConfig as FlowProcedureConfig),
connectionId: connId,
procedureName: "",
parameters: [],
};
setFormData({ ...formData, integrationConfig: newConfig });
setProcedureParams([]);
setLoadingProcedures(true);
try {
const res = await getFlowProcedures("external", connId);
if (res.success && res.data) setProcedureList(res.data);
} catch (e) {
console.error("외부 프로시저 목록 조회 실패:", e);
} finally {
setLoadingProcedures(false);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* 프로시저 선택 */}
{((formData.integrationConfig as FlowProcedureConfig)?.dbSource === "internal" ||
(formData.integrationConfig as FlowProcedureConfig)?.connectionId) && (
<>
<div>
<Label>/ </Label>
{loadingProcedures ? (
<div className="py-2 text-sm text-muted-foreground"> ...</div>
) : (
<Popover open={openProcedureCombobox} onOpenChange={setOpenProcedureCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openProcedureCombobox}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName || "프로시저 선택"}
<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>
{procedureList.map((proc) => (
<CommandItem
key={`${proc.schema}.${proc.name}`}
value={proc.name}
onSelect={async () => {
const procConfig = formData.integrationConfig as FlowProcedureConfig;
const newConfig: FlowProcedureConfig = {
...procConfig,
procedureName: proc.name,
procedureSchema: proc.schema,
callType: proc.type === "PROCEDURE" ? "procedure" : "function",
parameters: [],
};
setFormData({ ...formData, integrationConfig: newConfig });
setOpenProcedureCombobox(false);
setLoadingProcedureParams(true);
try {
const res = await getFlowProcedureParameters(
proc.name,
procConfig.dbSource,
procConfig.connectionId,
proc.schema,
);
if (res.success && res.data) {
setProcedureParams(res.data);
const mappedParams: FlowProcedureParam[] = res.data.map((p) => ({
name: p.name,
dataType: p.dataType,
mode: p.mode,
source: "record_field" as const,
field: "",
value: "",
}));
setFormData((prev) => ({
...prev,
integrationConfig: {
...(prev.integrationConfig as FlowProcedureConfig),
procedureName: proc.name,
procedureSchema: proc.schema,
callType: proc.type === "PROCEDURE" ? "procedure" : "function",
parameters: mappedParams,
},
}));
}
} catch (e) {
console.error("파라미터 조회 실패:", e);
} finally {
setLoadingProcedureParams(false);
}
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
(formData.integrationConfig as FlowProcedureConfig)?.procedureName === proc.name
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{proc.name}</span>
<span className="text-[10px] text-gray-500">
{proc.type} | {proc.schema}
{proc.returnType ? ` | 반환: ${proc.returnType}` : ""}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{procedureList.length === 0 && !loadingProcedures && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={async () => {
const procConfig = formData.integrationConfig as FlowProcedureConfig;
setLoadingProcedures(true);
try {
const res = await getFlowProcedures(
procConfig.dbSource,
procConfig.connectionId,
);
if (res.success && res.data) setProcedureList(res.data);
} catch (e) {
console.error("프로시저 목록 조회 실패:", e);
} finally {
setLoadingProcedures(false);
}
}}
>
</Button>
)}
</div>
{/* 호출 타입 */}
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName && (
<div>
<Label> </Label>
<Select
value={(formData.integrationConfig as FlowProcedureConfig)?.callType || "function"}
onValueChange={(value: "procedure" | "function") => {
setFormData({
...formData,
integrationConfig: {
...(formData.integrationConfig as FlowProcedureConfig),
callType: value,
},
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procedure">
<div>
<div className="font-medium">CALL ()</div>
<div className="text-xs text-gray-500">CALL procedure_name()</div>
</div>
</SelectItem>
<SelectItem value="function">
<div>
<div className="font-medium">SELECT ()</div>
<div className="text-xs text-gray-500">SELECT function_name()</div>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 파라미터 매핑 테이블 */}
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName && (
<div>
<Label> </Label>
{loadingProcedureParams ? (
<div className="py-2 text-sm text-muted-foreground"> ...</div>
) : (formData.integrationConfig as FlowProcedureConfig)?.parameters?.length === 0 ? (
<div className="rounded-md bg-gray-50 p-3">
<p className="text-sm text-gray-600"> .</p>
</div>
) : (
<div className="space-y-3">
{(formData.integrationConfig as FlowProcedureConfig)?.parameters?.map((param, idx) => (
<div key={idx} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{param.name}</span>
<span className="text-xs text-muted-foreground">
{param.dataType} | {param.mode}
</span>
</div>
{param.mode !== "OUT" && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Select
value={param.source}
onValueChange={(value: "record_field" | "static" | "step_variable") => {
const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters];
params[idx] = { ...params[idx], source: value };
setFormData({
...formData,
integrationConfig: {
...(formData.integrationConfig as FlowProcedureConfig),
parameters: params,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="record_field"> </SelectItem>
<SelectItem value="static"></SelectItem>
<SelectItem value="step_variable"> </SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">
{param.source === "static" ? "값" : "필드명"}
</Label>
<Input
className="h-8 text-xs"
placeholder={param.source === "static" ? "고정값 입력" : "컬럼명 입력"}
value={param.source === "static" ? param.value || "" : param.field || ""}
onChange={(e) => {
const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters];
if (param.source === "static") {
params[idx] = { ...params[idx], value: e.target.value };
} else {
params[idx] = { ...params[idx], field: e.target.value };
}
setFormData({
...formData,
integrationConfig: {
...(formData.integrationConfig as FlowProcedureConfig),
parameters: params,
},
});
}}
/>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
.
<br /> .
</p>
</div>
</>
)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -45,6 +45,7 @@ import {
DialogDescription, DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { CompanySwitcher } from "@/components/admin/CompanySwitcher"; import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
// useAuth의 UserInfo 타입을 확장 // useAuth의 UserInfo 타입을 확장
interface ExtendedUserInfo { interface ExtendedUserInfo {
@ -74,8 +75,13 @@ interface AppLayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
// 메뉴 아이콘 매핑 함수 // 메뉴 아이콘 매핑 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback)
const getMenuIcon = (menuName: string) => { const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
if (dbIconName) {
const DbIcon = getIconComponent(dbIconName);
if (DbIcon) return <DbIcon className="h-4 w-4" />;
}
const name = menuName.toLowerCase(); const name = menuName.toLowerCase();
if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />; if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />;
if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />; if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />;
@ -205,7 +211,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
return { return {
id: menuId, id: menuId,
name: getDisplayText(menu), name: getDisplayText(menu),
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""), icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#", url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined, children: children.length > 0 ? children : undefined,
hasChildren: children.length > 0, hasChildren: children.length > 0,

View File

@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBr
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MenuItem } from "@/types/menu"; import { MenuItem } from "@/types/menu";
import { MENU_ICONS, MESSAGES } from "@/constants/layout"; import { MENU_ICONS, MESSAGES } from "@/constants/layout";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
interface MainSidebarProps { interface MainSidebarProps {
menuList: MenuItem[]; menuList: MenuItem[];
@ -11,9 +12,14 @@ interface MainSidebarProps {
} }
/** /**
* * (DB , fallback)
*/ */
const getMenuIcon = (menuName: string) => { const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
if (dbIconName) {
const DbIcon = getIconComponent(dbIconName);
if (DbIcon) return <DbIcon className="h-4 w-4" />;
}
if (MENU_ICONS.HOME.some((keyword) => menuName.includes(keyword))) { if (MENU_ICONS.HOME.some((keyword) => menuName.includes(keyword))) {
return <Home className="h-4 w-4" />; return <Home className="h-4 w-4" />;
} }
@ -57,7 +63,7 @@ export function MainSidebar({ menuList, expandedMenus, onMenuClick, className =
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "")} {getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "", menu.MENU_ICON || menu.menu_icon)}
<span>{menu.MENU_NAME_KOR || menu.menuNameKor || "메뉴"}</span> <span>{menu.MENU_NAME_KOR || menu.menuNameKor || "메뉴"}</span>
</div> </div>
{hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)} {hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)}

View File

@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview"; import { NumberingRulePreview } from "./NumberingRulePreview";
@ -399,10 +400,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
await onSave?.(response.data); await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다"); toast.success("채번 규칙이 저장되었습니다");
} else { } else {
toast.error(response.error || "저장 실패"); showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(`저장 실패: ${error.message}`); showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -446,10 +447,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
toast.success("규칙이 삭제되었습니다"); toast.success("규칙이 삭제되었습니다");
} else { } else {
toast.error(response.error || "삭제 실패"); showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(`삭제 실패: ${error.message}`); showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -33,6 +33,7 @@ import {
Save, Save,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup"; import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
@ -191,7 +192,7 @@ export function PopScreenSettingModal({
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error("저장 실패:", error); console.error("저장 실패:", error);
toast.error("저장에 실패했습니다."); showErrorToast("POP 화면 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -15,6 +15,7 @@ import { ko } from "date-fns/locale";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file"; import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management"; import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management";
import { import {
@ -1265,7 +1266,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
} catch (error) { } catch (error) {
// console.error("파일 업로드 오류:", error); // console.error("파일 업로드 오류:", error);
toast.error("파일 업로드에 실패했습니다."); showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." });
// 파일 입력 초기화 // 파일 입력 초기화
e.target.value = ""; e.target.value = "";

View File

@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Search, Monitor, Settings, X, Plus } from "lucide-react"; import { Search, Monitor, Settings, X, Plus } from "lucide-react";
import { menuScreenApi } from "@/lib/api/screen"; import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -93,7 +94,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setMenus(allMenus); setMenus(allMenus);
} catch (error) { } catch (error) {
// console.error("메뉴 목록 로드 실패:", error); // console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다."); showErrorToast("메뉴 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -41,6 +41,7 @@ import {
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -403,7 +404,7 @@ export default function NodeSettingModal({
]); ]);
toast.success("데이터가 새로고침되었습니다."); toast.success("데이터가 새로고침되었습니다.");
} catch (error) { } catch (error) {
toast.error("새로고침 실패"); showErrorToast("새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -635,10 +636,10 @@ function TableRelationTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };
@ -653,10 +654,10 @@ function TableRelationTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "삭제에 실패했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };
@ -1178,10 +1179,10 @@ function JoinSettingTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };
@ -1196,10 +1197,10 @@ function JoinSettingTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "삭제에 실패했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };
@ -1586,10 +1587,10 @@ function DataFlowTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다."); showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };
@ -1604,10 +1605,10 @@ function DataFlowTab({
onReload(); onReload();
onRefreshVisualization?.(); onRefreshVisualization?.();
} else { } else {
toast.error(response.message || "삭제에 실패했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다."); showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Save, Loader2 } from "lucide-react"; import { X, Save, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
@ -76,7 +77,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
} }
} catch (error) { } catch (error) {
console.error("화면 로드 실패:", error); console.error("화면 로드 실패:", error);
toast.error("화면을 불러오는데 실패했습니다."); showErrorToast("화면 구성 정보를 불러오는 데 실패했습니다", error, {
guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -264,7 +267,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
} catch (error: any) { } catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음 // ❌ 저장 실패 - 모달은 닫히지 않음
console.error("저장 실패:", error); console.error("저장 실패:", error);
toast.error(`저장 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`); showErrorToast("데이터 저장에 실패했습니다", error, {
guidance: "입력 값을 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }

View File

@ -22,6 +22,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup"; import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { import {
Command, Command,
@ -225,11 +226,11 @@ export function ScreenGroupModal({
onSuccess(); onSuccess();
onClose(); onClose();
} else { } else {
toast.error(response.message || "작업에 실패했습니다"); showErrorToast("그룹 저장에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
console.error("그룹 저장 실패:", error); console.error("그룹 저장 실패:", error);
toast.error("그룹 저장에 실패했습니다"); showErrorToast("그룹 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -89,6 +89,7 @@ import { Check, ChevronsUpDown } from "lucide-react";
import { ScreenGroupModal } from "./ScreenGroupModal"; import { ScreenGroupModal } from "./ScreenGroupModal";
import CopyScreenModal from "./CopyScreenModal"; import CopyScreenModal from "./CopyScreenModal";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
interface ScreenGroupTreeViewProps { interface ScreenGroupTreeViewProps {
@ -581,11 +582,11 @@ export function ScreenGroupTreeView({
await loadGroupsData(); await loadGroupsData();
window.dispatchEvent(new CustomEvent("screen-list-refresh")); window.dispatchEvent(new CustomEvent("screen-list-refresh"));
} else { } else {
toast.error(response.message || "그룹 삭제에 실패했습니다"); showErrorToast("그룹 삭제에 실패했습니다", response.message, { guidance: "하위 항목이 있는 경우 먼저 삭제해 주세요." });
} }
} catch (error) { } catch (error) {
console.error("그룹 삭제 실패:", error); console.error("그룹 삭제 실패:", error);
toast.error("그룹 삭제에 실패했습니다"); showErrorToast("그룹 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setDeleteProgress({ current: 0, total: 0, message: "" }); setDeleteProgress({ current: 0, total: 0, message: "" });
@ -614,7 +615,7 @@ export function ScreenGroupTreeView({
window.dispatchEvent(new CustomEvent("screen-list-refresh")); window.dispatchEvent(new CustomEvent("screen-list-refresh"));
} catch (error) { } catch (error) {
console.error("화면 삭제 실패:", error); console.error("화면 삭제 실패:", error);
toast.error("화면 삭제에 실패했습니다"); showErrorToast("화면 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setIsScreenDeleting(false); setIsScreenDeleting(false);
setIsScreenDeleteDialogOpen(false); setIsScreenDeleteDialogOpen(false);
@ -765,7 +766,7 @@ export function ScreenGroupTreeView({
window.dispatchEvent(new CustomEvent("screen-list-refresh")); window.dispatchEvent(new CustomEvent("screen-list-refresh"));
} catch (error) { } catch (error) {
console.error("화면 수정 실패:", error); console.error("화면 수정 실패:", error);
toast.error("화면 수정에 실패했습니다"); showErrorToast("화면 정보 수정에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setIsEditScreenModalOpen(false); setIsEditScreenModalOpen(false);
setEditingScreen(null); setEditingScreen(null);

View File

@ -53,6 +53,7 @@ import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeU
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { showErrorToast } from "@/lib/utils/toastUtils";
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화) // InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
const InteractiveScreenViewer = dynamic( const InteractiveScreenViewer = dynamic(
@ -683,7 +684,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
setPreviewLayout(layoutData); setPreviewLayout(layoutData);
} catch (error) { } catch (error) {
console.error("❌ 레이아웃 로드 실패:", error); console.error("❌ 레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다."); showErrorToast("화면 레이아웃을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally { } finally {
setIsLoadingPreview(false); setIsLoadingPreview(false);
} }

View File

@ -12,6 +12,7 @@ import {
import { generateComponentId } from "@/lib/utils/generateId"; import { generateComponentId } from "@/lib/utils/generateId";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
@ -53,7 +54,7 @@ export default function SimpleScreenDesigner({ selectedScreen, onBackToList }: S
toast.success("화면이 저장되었습니다."); toast.success("화면이 저장되었습니다.");
} catch (error) { } catch (error) {
// console.error("저장 실패:", error); // console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("화면 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }

View File

@ -29,6 +29,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react"; import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
import { import {
getFieldJoins, getFieldJoins,
@ -155,7 +156,7 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("필드 조인 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}; };
@ -172,7 +173,7 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
toast.error(response.message || "삭제에 실패했습니다."); toast.error(response.message || "삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
toast.error("삭제 중 오류가 발생했습니다."); showErrorToast("필드 조인 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}; };

View File

@ -14,6 +14,7 @@ import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upl
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize, cn } from "@/lib/utils"; import { formatFileSize, cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
interface FileComponentConfigPanelProps { interface FileComponentConfigPanelProps {
component: FileComponent; component: FileComponent;
@ -536,7 +537,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
// fieldName // fieldName
// }); // });
toast.dismiss(); toast.dismiss();
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`); showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." });
} finally { } finally {
// console.log("🏁 파일 업로드 완료, 로딩 상태 해제"); // console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
setUploading(false); setUploading(false);
@ -554,7 +555,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`); toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`);
} catch (error) { } catch (error) {
// console.error('파일 다운로드 오류:', error); // console.error('파일 다운로드 오류:', error);
toast.error('파일 다운로드에 실패했습니다.'); showErrorToast("파일 다운로드에 실패했습니다", error, { guidance: "파일이 존재하는지 확인하고 다시 시도해 주세요." });
} }
}, []); }, []);
@ -677,7 +678,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
toast.success('파일이 삭제되었습니다.'); toast.success('파일이 삭제되었습니다.');
} catch (error) { } catch (error) {
// console.error('파일 삭제 오류:', error); // console.error('파일 삭제 오류:', error);
toast.error('파일 삭제에 실패했습니다.'); showErrorToast("파일 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}, [uploadedFiles, onUpdateProperty, component.id]); }, [uploadedFiles, onUpdateProperty, component.id]);
@ -713,7 +714,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`); toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`);
} catch (error) { } catch (error) {
// console.error('파일 저장 오류:', error); // console.error('파일 저장 오류:', error);
toast.error('파일 저장에 실패했습니다.'); showErrorToast("파일 저장에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." });
} }
}, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]); }, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]);

View File

@ -36,6 +36,7 @@ import { SingleTableWithSticky } from "@/lib/registry/components/table-list/Sing
import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; import type { ColumnConfig } from "@/lib/registry/components/table-list/types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@ -265,7 +266,7 @@ export function FlowWidget({
setSearchValues({}); setSearchValues({});
} catch (error) { } catch (error) {
console.error("필터 설정 저장 실패:", error); console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다"); showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}, [filterSettingKey, searchFilterColumns]); }, [filterSettingKey, searchFilterColumns]);
@ -309,7 +310,7 @@ export function FlowWidget({
toast.success("그룹 설정이 저장되었습니다"); toast.success("그룹 설정이 저장되었습니다");
} catch (error) { } catch (error) {
console.error("그룹 설정 저장 실패:", error); console.error("그룹 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다"); showErrorToast("그룹 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}, [groupSettingKey, groupByColumns]); }, [groupSettingKey, groupByColumns]);
@ -514,7 +515,7 @@ export function FlowWidget({
} }
} catch (err: any) { } catch (err: any) {
console.error("❌ 플로우 새로고침 실패:", err); console.error("❌ 플로우 새로고침 실패:", err);
toast.error(err.message || "데이터를 새로고치는데 실패했습니다"); showErrorToast("데이터 새로고침에 실패했습니다", err, { guidance: "네트워크 연결을 확인하고 다시 시도해 주세요." });
} finally { } finally {
if (selectedStepId) { if (selectedStepId) {
setStepDataLoading(false); setStepDataLoading(false);
@ -747,7 +748,7 @@ export function FlowWidget({
} }
} catch (err: any) { } catch (err: any) {
console.error("Failed to load step data:", err); console.error("Failed to load step data:", err);
toast.error(err.message || "데이터를 불러오는데 실패했습니다"); showErrorToast("스텝 데이터를 불러오는 데 실패했습니다", err, { guidance: "네트워크 연결을 확인해 주세요." });
} finally { } finally {
setStepDataLoading(false); setStepDataLoading(false);
} }
@ -1023,7 +1024,7 @@ export function FlowWidget({
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
} catch (error) { } catch (error) {
console.error("Excel 내보내기 오류:", error); console.error("Excel 내보내기 오류:", error);
toast.error("Excel 내보내기에 실패했습니다."); showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]);
@ -1188,7 +1189,7 @@ export function FlowWidget({
toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" });
} catch (error) { } catch (error) {
console.error("PDF 내보내기 오류:", error); console.error("PDF 내보내기 오류:", error);
toast.error("PDF 내보내기에 실패했습니다.", { id: "pdf-export" }); showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]);
@ -1216,7 +1217,7 @@ export function FlowWidget({
toast.success(`${copyData.length}개 행이 클립보드에 복사되었습니다.`); toast.success(`${copyData.length}개 행이 클립보드에 복사되었습니다.`);
} catch (error) { } catch (error) {
console.error("복사 오류:", error); console.error("복사 오류:", error);
toast.error("복사에 실패했습니다."); showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
} }
}, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]); }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]);
@ -1318,7 +1319,7 @@ export function FlowWidget({
toast.success("데이터를 새로고침했습니다."); toast.success("데이터를 새로고침했습니다.");
} catch (error) { } catch (error) {
console.error("새로고침 오류:", error); console.error("새로고침 오류:", error);
toast.error("새로고침에 실패했습니다."); showErrorToast("데이터 새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setStepDataLoading(false); setStepDataLoading(false);
} }
@ -1399,7 +1400,7 @@ export function FlowWidget({
} }
} catch (error) { } catch (error) {
console.error("편집 저장 오류:", error); console.error("편집 저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
cancelEditing(); cancelEditing();

View File

@ -48,6 +48,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
onRowClick, onRowClick,
className, className,
formData: parentFormData, formData: parentFormData,
groupedData,
...restProps ...restProps
}) => { }) => {
// componentId 결정: 직접 전달 또는 component 객체에서 추출 // componentId 결정: 직접 전달 또는 component 객체에서 추출
@ -419,65 +420,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
fkValue, fkValue,
}); });
const response = await apiClient.post( let rows: any[] = [];
`/table-management/tables/${config.mainTableName}/data`, const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
{
if (useEntityJoinForLoad) {
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
const params: Record<string, any> = {
page: 1, page: 1,
size: 1000, size: 1000,
dataFilter: { search: searchParam,
enabled: true, enableEntityJoin: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], autoFilter: JSON.stringify({ enabled: true }),
}, };
autoFilter: true, const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns;
if (addJoinCols && addJoinCols.length > 0) {
params.additionalJoinColumns = JSON.stringify(addJoinCols);
} }
); const response = await apiClient.get(
`/table-management/tables/${config.mainTableName}/data-with-joins`,
{ params }
);
const resultData = response.data?.data;
const rawRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거
const seenIds = new Set<string>();
rows = rawRows.filter((row: any) => {
if (!row.id || seenIds.has(row.id)) return false;
seenIds.add(row.id);
return true;
});
} else {
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
}
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) { if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); const columnMapping = config.sourceDetailConfig?.columnMapping;
const sourceTable = config.dataSource?.sourceTable; if (useEntityJoinForLoad && columnMapping) {
const fkColumn = config.dataSource?.foreignKey; const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const refKey = config.dataSource?.referenceKey || "id"; rows.forEach((row: any) => {
sourceDisplayColumns.forEach((col) => {
const mappedKey = columnMapping[col.key];
const value = mappedKey ? row[mappedKey] : row[col.key];
row[`_display_${col.key}`] = value ?? "";
});
});
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
}
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
try { if (!useEntityJoinForLoad) {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const uniqueValues = [...new Set(fkValues)]; const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (uniqueValues.length > 0) { if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 try {
const sourcePromises = uniqueValues.map((val) => const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
apiClient.post(`/table-management/tables/${sourceTable}/data`, { const uniqueValues = [...new Set(fkValues)];
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
// 각 행에 소스 테이블의 표시 데이터 병합 if (uniqueValues.length > 0) {
rows.forEach((row: any) => { const sourcePromises = uniqueValues.map((val) =>
const sourceRecord = sourceMap.get(String(row[fkColumn])); apiClient.post(`/table-management/tables/${sourceTable}/data`, {
if (sourceRecord) { page: 1, size: 1,
sourceDisplayColumns.forEach((col) => { search: { [refKey]: val },
const displayValue = sourceRecord[col.key] ?? null; autoFilter: true,
row[col.key] = displayValue; }).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
row[`_display_${col.key}`] = displayValue; .catch(() => [])
}); );
} const sourceResults = await Promise.all(sourcePromises);
}); const sourceMap = new Map<string, any>();
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
} }
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
} }
} }
@ -964,8 +1013,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
[], [],
); );
// V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
// EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
const sourceDetailLoadedRef = useRef(false);
useEffect(() => {
if (sourceDetailLoadedRef.current) return;
if (!groupedData || groupedData.length === 0) return;
if (!config.sourceDetailConfig) return;
const { tableName, foreignKey, parentKey } = config.sourceDetailConfig;
if (!tableName || !foreignKey || !parentKey) return;
const parentKeys = groupedData
.map((row) => row[parentKey])
.filter((v) => v !== undefined && v !== null && v !== "");
if (parentKeys.length === 0) return;
sourceDetailLoadedRef.current = true;
const loadSourceDetails = async () => {
try {
const uniqueKeys = [...new Set(parentKeys)] as string[];
const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!;
let detailRows: any[] = [];
if (useEntityJoin) {
// data-with-joins GET API 사용 (엔티티 조인 자동 적용)
const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") });
const params: Record<string, any> = {
page: 1,
size: 9999,
search: searchParam,
enableEntityJoin: true,
autoFilter: JSON.stringify({ enabled: true }),
};
if (additionalJoinColumns && additionalJoinColumns.length > 0) {
params.additionalJoinColumns = JSON.stringify(additionalJoinColumns);
}
const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params });
const resultData = resp.data?.data;
const rawRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거
const seenIds = new Set<string>();
detailRows = rawRows.filter((row: any) => {
if (!row.id || seenIds.has(row.id)) return false;
seenIds.add(row.id);
return true;
});
} else {
// 기존 POST API 사용
const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 9999,
search: { [foreignKey]: uniqueKeys },
});
const resultData = resp.data?.data;
detailRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
}
if (detailRows.length === 0) {
console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys });
return;
}
console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : "");
// 디테일 행을 리피터 컬럼에 매핑
const newRows = detailRows.map((detail, index) => {
const row: any = { _id: `src_detail_${Date.now()}_${index}` };
for (const col of config.columns) {
if (col.isSourceDisplay) {
// columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용)
const mappedKey = columnMapping?.[col.key];
const value = mappedKey ? detail[mappedKey] : detail[col.key];
row[`_display_${col.key}`] = value ?? "";
// 원본 값도 저장 (DB persist용 - _display_ 접두사 없이)
if (detail[col.key] !== undefined) {
row[col.key] = detail[col.key];
}
} else if (col.autoFill) {
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
row[col.key] = autoValue ?? "";
} else if (col.sourceKey && detail[col.sourceKey] !== undefined) {
row[col.key] = detail[col.sourceKey];
} else if (detail[col.key] !== undefined) {
row[col.key] = detail[col.key];
} else {
row[col.key] = "";
}
}
return row;
});
setData(newRows);
onDataChange?.(newRows);
} catch (error) {
console.error("[V2Repeater] sourceDetail 조회 실패:", error);
}
};
loadSourceDetails();
}, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]);
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
useEffect(() => { useEffect(() => {

View File

@ -80,7 +80,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options {options
.filter((option) => option.value !== "") .filter((option) => option.value != null && option.value !== "")
.map((option) => ( .map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -112,6 +112,12 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
} }
// 검색 가능 또는 다중 선택 → Combobox 사용 // 검색 가능 또는 다중 선택 → Combobox 사용
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
const safeOptions = useMemo(() =>
options.filter((o) => o.value != null && o.value !== ""),
[options]
);
const selectedValues = useMemo(() => { const selectedValues = useMemo(() => {
if (!value) return []; if (!value) return [];
return Array.isArray(value) ? value : [value]; return Array.isArray(value) ? value : [value];
@ -119,9 +125,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
const selectedLabels = useMemo(() => { const selectedLabels = useMemo(() => {
return selectedValues return selectedValues
.map((v) => options.find((o) => o.value === v)?.label) .map((v) => safeOptions.find((o) => o.value === v)?.label)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
}, [selectedValues, options]); }, [selectedValues, safeOptions]);
const handleSelect = useCallback((selectedValue: string) => { const handleSelect = useCallback((selectedValue: string) => {
if (multiple) { if (multiple) {
@ -191,7 +197,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<Command <Command
filter={(itemValue, search) => { filter={(itemValue, search) => {
if (!search) return 1; if (!search) return 1;
const option = options.find((o) => o.value === itemValue); const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase(); const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1; if (label.includes(search.toLowerCase())) return 1;
return 0; return 0;
@ -201,7 +207,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<CommandList> <CommandList>
<CommandEmpty> .</CommandEmpty> <CommandEmpty> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => { {safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)"; const displayLabel = option.label || option.value || "(빈 값)";
return ( return (
<CommandItem <CommandItem
@ -869,7 +875,11 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
} }
setOptions(fetchedOptions); // null/undefined value 필터링 (cmdk 크래시 방지)
const sanitized = fetchedOptions.filter(
(o) => o.value != null && String(o.value) !== ""
).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
setOptions(sanitized);
setOptionsLoaded(true); setOptionsLoaded(true);
} catch (error) { } catch (error) {
console.error("옵션 로딩 실패:", error); console.error("옵션 로딩 실패:", error);

View File

@ -31,6 +31,7 @@ import {
Wand2, Wand2,
Check, Check,
ChevronsUpDown, ChevronsUpDown,
ListTree,
} from "lucide-react"; } from "lucide-react";
import { import {
Command, Command,
@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<Separator /> <Separator />
{/* 소스 디테일 자동 조회 설정 */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enableSourceDetail"
checked={!!config.sourceDetailConfig}
onCheckedChange={(checked) => {
if (checked) {
updateConfig({
sourceDetailConfig: {
tableName: "",
foreignKey: "",
parentKey: "",
},
});
} else {
updateConfig({ sourceDetailConfig: undefined });
}
}}
/>
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
<ListTree className="h-3 w-3" />
</label>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
{config.sourceDetailConfig && (
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{config.sourceDetailConfig.tableName
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
: "테이블 선택..."
}
<ChevronsUpDown className="ml-2 h-3 w-3 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" />
<CommandList className="max-h-48">
<CommandEmpty className="text-xs py-3 text-center"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
tableName: table.tableName,
},
});
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<span>{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> FK </Label>
<Input
value={config.sourceDetailConfig.foreignKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
foreignKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.sourceDetailConfig.parentKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
parentKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-violet-600">
[{config.sourceDetailConfig.parentKey || "?"}]
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"}
</p>
</div>
)}
</div>
<Separator />
{/* 기능 옵션 */} {/* 기능 옵션 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>

View File

@ -38,7 +38,9 @@ export function MenuProvider({ children }: { children: ReactNode }) {
regdate: item.REGDATE || item.regdate, regdate: item.REGDATE || item.regdate,
company_code: item.COMPANY_CODE || item.company_code, company_code: item.COMPANY_CODE || item.company_code,
company_name: item.COMPANY_NAME || item.company_name, company_name: item.COMPANY_NAME || item.company_name,
// 다국어 관련 필드 추가 // 아이콘 필드
menu_icon: item.MENU_ICON || item.menu_icon,
// 다국어 관련 필드
lang_key: item.LANG_KEY || item.lang_key, lang_key: item.LANG_KEY || item.lang_key,
lang_key_desc: item.LANG_KEY_DESC || item.lang_key_desc, lang_key_desc: item.LANG_KEY_DESC || item.lang_key_desc,
translated_name: item.TRANSLATED_NAME || item.translated_name, translated_name: item.TRANSLATED_NAME || item.translated_name,

View File

@ -24,6 +24,7 @@ import { usePopEvent } from "./usePopEvent";
import { executePopAction } from "./executePopAction"; import { executePopAction } from "./executePopAction";
import type { ActionResult } from "./executePopAction"; import type { ActionResult } from "./executePopAction";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
// ======================================== // ========================================
// 타입 정의 // 타입 정의
@ -107,7 +108,7 @@ export function usePopAction(screenId: string) {
const msg = ACTION_SUCCESS_MESSAGES[action.type]; const msg = ACTION_SUCCESS_MESSAGES[action.type];
if (msg) toast.success(msg); if (msg) toast.success(msg);
} else { } else {
toast.error(result.error || "작업에 실패했습니다."); showErrorToast("작업에 실패했습니다", result.error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
// 성공 시 후속 액션 실행 // 성공 시 후속 액션 실행

View File

@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ suc
*/ */
export async function moveDataToNextStep( export async function moveDataToNextStep(
flowId: number, flowId: number,
currentStepId: number, fromStepId: number,
dataId: number, toStepId: number,
recordId: string | number,
): Promise<ApiResponse<{ success: boolean }>> { ): Promise<ApiResponse<{ success: boolean }>> {
return moveData({ return moveData({
flowId, flowId,
currentStepId, fromStepId,
dataId, recordId: String(recordId),
toStepId,
}); });
} }
@ -559,3 +561,61 @@ export async function updateFlowStepData(
}; };
} }
} }
// ============================================
// 프로시저/함수 API
// ============================================
import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb";
/**
* /
*/
export async function getFlowProcedures(
dbSource: "internal" | "external",
connectionId?: number,
schema?: string,
): Promise<ApiResponse<ProcedureListItem[]>> {
try {
const params = new URLSearchParams({ dbSource });
if (connectionId) params.set("connectionId", String(connectionId));
if (schema) params.set("schema", schema);
const response = await fetch(`${API_BASE}/flow/procedures?${params.toString()}`, {
headers: getAuthHeaders(),
credentials: "include",
});
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* /
*/
export async function getFlowProcedureParameters(
name: string,
dbSource: "internal" | "external",
connectionId?: number,
schema?: string,
): Promise<ApiResponse<ProcedureParameterInfo[]>> {
try {
const params = new URLSearchParams({ dbSource });
if (connectionId) params.set("connectionId", String(connectionId));
if (schema) params.set("schema", schema);
const response = await fetch(
`${API_BASE}/flow/procedures/${encodeURIComponent(name)}/parameters?${params.toString()}`,
{
headers: getAuthHeaders(),
credentials: "include",
},
);
return await response.json();
} catch (error: any) {
return { success: false, error: error.message };
}
}

View File

@ -40,6 +40,8 @@ export interface MenuItem {
TRANSLATED_NAME?: string; TRANSLATED_NAME?: string;
translated_desc?: string; translated_desc?: string;
TRANSLATED_DESC?: string; TRANSLATED_DESC?: string;
menu_icon?: string;
MENU_ICON?: string;
} }
export interface MenuFormData { export interface MenuFormData {
@ -52,8 +54,9 @@ export interface MenuFormData {
menuType: string; menuType: string;
status: string; status: string;
companyCode: string; companyCode: string;
langKey?: string; // 다국어 키 추가 langKey?: string;
screenCode?: string; // 화면 코드 추가 screenCode?: string;
menuIcon?: string;
} }
export interface LangKey { export interface LangKey {

View File

@ -20,6 +20,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
@ -955,7 +956,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 데이터 전달 실패:", error); console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); showErrorToast("데이터 전달에 실패했습니다", error, { guidance: "대상 화면 설정과 데이터를 확인해 주세요." });
} }
}; };

View File

@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile"; import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils"; import { formatFileSize } from "@/lib/utils";
@ -881,7 +882,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.error("파일 업로드 오류:", error); console.error("파일 업로드 오류:", error);
setUploadStatus("error"); setUploadStatus("error");
toast.dismiss("file-upload"); toast.dismiss("file-upload");
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." });
} }
}, },
[safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData], [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData],
@ -1006,7 +1007,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.success(`${fileName} 삭제 완료`); toast.success(`${fileName} 삭제 완료`);
} catch (error) { } catch (error) {
console.error("파일 삭제 오류:", error); console.error("파일 삭제 오류:", error);
toast.error("파일 삭제에 실패했습니다."); showErrorToast("파일 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}, },
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey], [uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],

View File

@ -553,14 +553,20 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field]; const value = row[column.field];
// 카테고리 라벨 변환 함수 // 카테고리/셀렉트 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => { const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-"; if (!val || typeof val !== "string") return val || "-";
// select 타입 컬럼의 selectOptions에서 라벨 찾기
if (column.selectOptions && column.selectOptions.length > 0) {
const matchedOption = column.selectOptions.find((opt) => opt.value === val);
if (matchedOption) return matchedOption.label;
}
const fieldName = column.field.replace(/^_display_/, ""); const fieldName = column.field.replace(/^_display_/, "");
const isCategoryColumn = categoryColumns.includes(fieldName); const isCategoryColumn = categoryColumns.includes(fieldName);
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) // categoryLabelMap에 직접 매핑이 있으면 바로 변환
if (categoryLabelMap[val]) return categoryLabelMap[val]; if (categoryLabelMap[val]) return categoryLabelMap[val];
// 카테고리 컬럼이 아니면 원래 값 반환 // 카테고리 컬럼이 아니면 원래 값 반환

View File

@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null); const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null); const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 카테고리 코드→라벨 매핑
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 프론트엔드 그룹핑 함수 // 프론트엔드 그룹핑 함수
const groupData = useCallback( const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => { (data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
} }
}); });
// 탭 목록 생성 // 탭 목록 생성 (카테고리 라벨 변환 적용)
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({ const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
id: value, id: value,
label: value, label: categoryLabelMap[value] || value,
count: tabConfig.showCount ? count : 0, count: tabConfig.showCount ? count : 0,
})); }));
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
return tabs; return tabs;
}, },
[], [categoryLabelMap],
); );
// 탭으로 필터링된 데이터 반환 // 탭으로 필터링된 데이터 반환
@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId); console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
break; break;
case "edit": case "edit": {
// 좌측 패널에서 수정 (필요시 구현) if (!selectedLeftItem) {
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn); toast.error("수정할 항목을 선택해주세요.");
return;
}
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
if (!editModalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
const editEvent = new CustomEvent("openEditModal", {
detail: {
screenId: editModalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData: selectedLeftItem,
isCreateMode: false,
onSave: () => {
loadLeftData();
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(editEvent);
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
break; break;
}
case "delete": case "delete":
// 좌측 패널에서 삭제 (필요시 구현) // 좌측 패널에서 삭제 (필요시 구현)
@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
break; break;
} }
}, },
[config.leftPanel?.addModalScreenId, loadLeftData], [config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
); );
// 컬럼 라벨 로드 // 컬럼 라벨 로드
@ -1241,6 +1273,55 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.rightPanel?.tableName, config.rightPanel?.tableName,
]); ]);
// 카테고리 컬럼에 대한 라벨 매핑 로드
useEffect(() => {
if (isDesignMode) return;
const loadCategoryLabels = async () => {
const allColumns = new Set<string>();
const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName;
if (!tableName) return;
// 좌우 패널의 표시 컬럼에서 카테고리 후보 수집
for (const col of config.leftPanel?.displayColumns || []) {
allColumns.add(col.name);
}
for (const col of config.rightPanel?.displayColumns || []) {
allColumns.add(col.name);
}
// 탭 소스 컬럼도 추가
if (config.rightPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.rightPanel.tabConfig.tabSourceColumn);
}
if (config.leftPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.leftPanel.tabConfig.tabSourceColumn);
}
const labelMap: Record<string, string> = {};
for (const columnName of allColumns) {
try {
const result = await getCategoryValues(tableName, columnName);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
}
}
}
} catch {
// 카테고리가 아닌 컬럼은 무시
}
}
if (Object.keys(labelMap).length > 0) {
setCategoryLabelMap(labelMap);
}
};
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 컴포넌트 언마운트 시 DataProvider 해제 // 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
}; };
}, [screenContext, component.id]); }, [screenContext, component.id]);
// 카테고리 코드를 라벨로 변환
const resolveCategoryLabel = useCallback(
(value: any): string => {
if (value === null || value === undefined) return "";
const strVal = String(value);
if (categoryLabelMap[strVal]) return categoryLabelMap[strVal];
// 콤마 구분 다중 값 처리
if (strVal.includes(",")) {
const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean);
const labels = codes.map((code) => categoryLabelMap[code] || code);
return labels.join(", ");
}
return strVal;
},
[categoryLabelMap],
);
// 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려) // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
const getColumnValue = useCallback( const getColumnValue = useCallback(
(item: any, col: ColumnConfig): any => { (item: any, col: ColumnConfig): any => {
@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const displayColumns = config.leftPanel?.displayColumns || []; const displayColumns = config.leftPanel?.displayColumns || [];
const pkColumn = getLeftPrimaryKeyColumn(); const pkColumn = getLeftPrimaryKeyColumn();
// 값 렌더링 (배지 지원) // 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
const renderCellValue = (item: any, col: ColumnConfig) => { const renderCellValue = (item: any, col: ColumnConfig) => {
const value = item[col.name]; const value = item[col.name];
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{value.map((v, vIdx) => ( {value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="text-xs"> <Badge key={vIdx} variant="secondary" className="text-xs">
{formatValue(v, col.format)} {resolveCategoryLabel(v) || formatValue(v, col.format)}
</Badge> </Badge>
))} ))}
</div> </div>
@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 배지 타입이지만 단일 값인 경우 // 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") { if (col.displayConfig?.displayType === "badge") {
const label = resolveCategoryLabel(value);
return ( return (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatValue(value, col.format)} {label !== String(value) ? label : formatValue(value, col.format)}
</Badge> </Badge>
); );
} }
// 기본 텍스트 // 카테고리 라벨 변환 시도 후 기본 텍스트
const label = resolveCategoryLabel(value);
if (label !== String(value)) return label;
return formatValue(value, col.format); return formatValue(value, col.format);
}; };
@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
/> />
</TableCell> </TableCell>
)} )}
{displayColumns.map((col, colIdx) => ( {displayColumns.map((col, colIdx) => {
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell> const rawVal = getColumnValue(item, col);
))} const resolved = resolveCategoryLabel(rawVal);
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
})}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center gap-1"> <div className="flex justify-center gap-1">
@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음) // 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.leftPanel.actionButtons.length > 0 && ( config.leftPanel.actionButtons.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{config.leftPanel.actionButtons.map((btn, idx) => ( {config.leftPanel.actionButtons
.filter((btn) => {
if (btn.showCondition === "selected") return !!selectedLeftItem;
return true;
})
.map((btn, idx) => (
<Button <Button
key={idx} key={idx}
size="sm" size="sm"

View File

@ -45,6 +45,7 @@ import { FileText, ChevronRightIcon, Search } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { tableDisplayStore } from "@/stores/tableDisplayStore";
import { import {
Dialog, Dialog,
@ -2491,7 +2492,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("✅ 배치 저장 완료:", pendingChanges.size, "개"); console.log("✅ 배치 저장 완료:", pendingChanges.size, "개");
} catch (error) { } catch (error) {
console.error("❌ 배치 저장 실패:", error); console.error("❌ 배치 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
@ -2709,7 +2710,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("✅ Excel 내보내기 완료:", fileName); console.log("✅ Excel 내보내기 완료:", fileName);
} catch (error) { } catch (error) {
console.error("❌ Excel 내보내기 실패:", error); console.error("❌ Excel 내보내기 실패:", error);
toast.error("Excel 내보내기 중 오류가 발생했습니다."); showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, },
[ [
@ -3623,7 +3624,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex }); console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex });
} catch (error) { } catch (error) {
console.error("❌ 행 순서 변경 실패:", error); console.error("❌ 행 순서 변경 실패:", error);
toast.error("순서 변경 중 오류가 발생했습니다."); showErrorToast("행 순서 변경에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
handleRowDragEnd(); handleRowDragEnd();
@ -3737,7 +3738,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
} catch (error) { } catch (error) {
console.error("❌ PDF 내보내기 실패:", error); console.error("❌ PDF 내보내기 실패:", error);
toast.error("PDF 내보내기 중 오류가 발생했습니다."); showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, },
[ [
@ -6644,7 +6645,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
handleRefresh(); handleRefresh();
} catch (error) { } catch (error) {
console.error("삭제 오류:", error); console.error("삭제 오류:", error);
toast.error("삭제 중 오류가 발생했습니다"); showErrorToast("데이터 삭제에 실패했습니다", error, { guidance: "삭제 대상을 확인하고 다시 시도해 주세요." });
} }
} }
closeContextMenu(); closeContextMenu();

View File

@ -514,29 +514,38 @@ export function TableSectionRenderer({
loadColumnLabels(); loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]); }, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 카테고리 타입 컬럼의 옵션 로드 // 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드
useEffect(() => { useEffect(() => {
const loadCategoryOptions = async () => { const loadCategoryOptions = async () => {
const sourceTableName = tableConfig.source.tableName; const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return; if (!sourceTableName) return;
if (!tableConfig.columns) return; if (!tableConfig.columns) return;
// 카테고리 타입인 컬럼만 필터링
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
if (categoryColumns.length === 0) return;
const newOptionsMap: Record<string, { value: string; label: string }[]> = {}; const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
const loadedSourceColumns = new Set<string>();
for (const col of categoryColumns) { const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
// 소스 필드 또는 필드명으로 카테고리 값 조회
const actualColumnName = col.sourceField || col.field; for (const col of tableConfig.columns) {
if (!actualColumnName) continue; let sourceColumnName: string | undefined;
if (col.type === "category") {
sourceColumnName = col.sourceField || col.field;
} else {
// referenceDisplay로 소스 카테고리 컬럼을 참조하는 컬럼도 포함
const refSource = (col as any).saveConfig?.referenceDisplay?.sourceColumn;
if (refSource && sourceCategoryColumns.includes(refSource)) {
sourceColumnName = refSource;
}
}
if (!sourceColumnName || loadedSourceColumns.has(`${col.field}:${sourceColumnName}`)) continue;
loadedSourceColumns.add(`${col.field}:${sourceColumnName}`);
try { try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); const result = await getCategoryValues(sourceTableName, sourceColumnName, false);
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
if (result?.success && Array.isArray(result.data)) {
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({ const options = result.data.map((item: any) => ({
value: item.valueCode || item.value_code || item.value || "", value: item.valueCode || item.value_code || item.value || "",
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "", label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
@ -548,11 +557,13 @@ export function TableSectionRenderer({
} }
} }
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); if (Object.keys(newOptionsMap).length > 0) {
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
}
}; };
loadCategoryOptions(); loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]); }, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]);
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
useEffect(() => { useEffect(() => {
@ -630,42 +641,81 @@ export function TableSectionRenderer({
const loadDynamicOptions = async () => { const loadDynamicOptions = async () => {
setDynamicOptionsLoading(true); setDynamicOptionsLoading(true);
try { try {
// DISTINCT 값을 가져오기 위한 API 호출 // 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { let categoryLabelMap: Record<string, string> = {};
search: filterCondition ? { _raw: filterCondition } : {}, try {
size: 1000, const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
page: 1, const catResult = await getCategoryValues(tableName, valueColumn, false);
}); if (catResult?.success && Array.isArray(catResult.data)) {
for (const item of catResult.data) {
if (response.data.success && response.data.data?.data) { const code = item.valueCode || item.value_code || item.value || "";
const rows = response.data.data.data; const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code;
if (code) categoryLabelMap[code] = label;
// 중복 제거하여 고유 값 추출
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const value = row[valueColumn];
if (value && !uniqueValues.has(value)) {
const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label);
} }
} }
} catch {
// 카테고리 값이 없으면 무시
}
// 옵션 배열로 변환 const hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
if (hasCategoryValues) {
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
id: `dynamic_${index}`, id: `dynamic_${index}`,
value, value: code,
label, label,
})); }));
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
tableName, tableName,
valueColumn, valueColumn,
optionCount: options.length, optionCount: options.length,
options,
}); });
setDynamicOptions(options); setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true; dynamicOptionsLoadedRef.current = true;
} else {
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {},
size: 1000,
page: 1,
});
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const rawValue = row[valueColumn];
if (!rawValue) continue;
// 쉼표 구분 다중값을 개별로 분리
const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean);
for (const v of values) {
if (!uniqueValues.has(v)) {
const label = labelColumn ? row[labelColumn] || v : v;
uniqueValues.set(v, label);
}
}
}
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
id: `dynamic_${index}`,
value,
label,
}));
console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
});
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
}
} }
} catch (error) { } catch (error) {
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
@ -1019,34 +1069,24 @@ export function TableSectionRenderer({
); );
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
// 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리
useEffect(() => { useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return; if (initialDataLoadedRef.current) return;
if (isConditionalMode) return;
const tableSectionKey = `__tableSection_${sectionId}`; const tableSectionKey = `__tableSection_${sectionId}`;
const initialData = formData[tableSectionKey]; const initialData = formData[tableSectionKey];
console.log("[TableSectionRenderer] 초기 데이터 확인:", {
sectionId,
tableSectionKey,
hasInitialData: !!initialData,
initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
});
if (Array.isArray(initialData) && initialData.length > 0) { if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", { console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", {
sectionId, sectionId,
itemCount: initialData.length, itemCount: initialData.length,
firstItem: initialData[0],
}); });
setTableData(initialData); setTableData(initialData);
initialDataLoadedRef.current = true; initialDataLoadedRef.current = true;
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
loadReferenceColumnValues(initialData); loadReferenceColumnValues(initialData);
} }
}, [sectionId, formData, loadReferenceColumnValues]); }, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]);
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => { const columns: RepeaterColumnConfig[] = useMemo(() => {
@ -1068,10 +1108,23 @@ export function TableSectionRenderer({
}); });
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 // categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생
const tableCategoryColumns = useMemo(() => { const tableCategoryColumns = useMemo(() => {
return Object.keys(categoryOptionsMap); const cols = new Set(Object.keys(categoryOptionsMap));
}, [categoryOptionsMap]); // 조건부 테이블의 conditionColumn과 매핑된 컬럼도 카테고리 컬럼으로 추가
if (isConditionalMode && conditionalConfig?.conditionColumn && dynamicOptions.length > 0) {
// 조건 컬럼 자체
cols.add(conditionalConfig.conditionColumn);
// referenceDisplay로 조건 컬럼의 소스를 참조하는 컬럼도 추가
for (const col of tableConfig.columns || []) {
const refDisplay = (col as any).saveConfig?.referenceDisplay;
if (refDisplay?.sourceColumn === conditionalConfig.conditionColumn) {
cols.add(col.field);
}
}
}
return Array.from(cols);
}, [categoryOptionsMap, isConditionalMode, conditionalConfig?.conditionColumn, dynamicOptions, tableConfig.columns]);
const tableCategoryLabelMap = useMemo(() => { const tableCategoryLabelMap = useMemo(() => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
@ -1082,8 +1135,14 @@ export function TableSectionRenderer({
} }
} }
} }
// 조건부 테이블 동적 옵션의 카테고리 코드→라벨 매핑도 추가
for (const opt of dynamicOptions) {
if (opt.value && opt.label && opt.value !== opt.label) {
map[opt.value] = opt.label;
}
}
return map; return map;
}, [categoryOptionsMap]); }, [categoryOptionsMap, dynamicOptions]);
// 원본 계산 규칙 (조건부 계산 포함) // 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo( const originalCalculationRules: TableCalculationRule[] = useMemo(
@ -1606,10 +1665,9 @@ export function TableSectionRenderer({
const multiSelect = uiConfig?.multiSelect ?? true; const multiSelect = uiConfig?.multiSelect ?? true;
// 버튼 표시 설정 (두 버튼 동시 표시 가능) // 버튼 표시 설정 (두 버튼 동시 표시 가능)
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환 // showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고
const legacyAddButtonType = uiConfig?.addButtonType; const showSearchButton = uiConfig?.showSearchButton ?? true;
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true); const showAddRowButton = uiConfig?.showAddRowButton ?? false;
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색"; const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
@ -1641,8 +1699,9 @@ export function TableSectionRenderer({
const filter = { ...baseFilterCondition }; const filter = { ...baseFilterCondition };
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
// __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원)
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition;
} }
return filter; return filter;
@ -1771,7 +1830,29 @@ export function TableSectionRenderer({
async (items: any[]) => { async (items: any[]) => {
if (!modalCondition) return; if (!modalCondition) return;
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 // autoFillColumns 매핑 빌드: targetField → sourceColumn
const autoFillMap: Record<string, string> = {};
for (const col of tableConfig.columns) {
const dso = (col as any).dynamicSelectOptions;
if (dso?.sourceField) {
autoFillMap[col.field] = dso.sourceField;
}
if (dso?.rowSelectionMode?.autoFillColumns) {
for (const af of dso.rowSelectionMode.autoFillColumns) {
autoFillMap[af.targetField] = af.sourceColumn;
}
}
}
// referenceDisplay에서도 매핑 추가
for (const col of tableConfig.columns) {
if (!autoFillMap[col.field]) {
const refDisplay = (col as any).saveConfig?.referenceDisplay;
if (refDisplay?.sourceColumn) {
autoFillMap[col.field] = refDisplay.sourceColumn;
}
}
}
const mappedItems = await Promise.all( const mappedItems = await Promise.all(
items.map(async (sourceItem) => { items.map(async (sourceItem) => {
const newItem: any = {}; const newItem: any = {};
@ -1779,6 +1860,15 @@ export function TableSectionRenderer({
for (const col of tableConfig.columns) { for (const col of tableConfig.columns) {
const mapping = col.valueMapping; const mapping = col.valueMapping;
// autoFill 또는 referenceDisplay 매핑이 있으면 우선 사용
const autoFillSource = autoFillMap[col.field];
if (!mapping && autoFillSource) {
if (sourceItem[autoFillSource] !== undefined) {
newItem[col.field] = sourceItem[autoFillSource];
}
continue;
}
// 소스 필드에서 값 복사 (기본) // 소스 필드에서 값 복사 (기본)
if (!mapping) { if (!mapping) {
const sourceField = col.sourceField || col.field; const sourceField = col.sourceField || col.field;
@ -1896,45 +1986,146 @@ export function TableSectionRenderer({
[addEmptyRowToCondition], [addEmptyRowToCondition],
); );
// 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
const applyConditionalGrouping = useCallback((data: any[]) => {
const conditionColumn = conditionalConfig?.conditionColumn;
console.warn(`[applyConditionalGrouping] 호출됨:`, {
conditionColumn,
dataLength: data.length,
sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]),
});
if (!conditionColumn || data.length === 0) return;
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of data) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}, [conditionalConfig?.conditionColumn]);
// 조건부 테이블: 초기 데이터 로드 (수정 모드) // 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return; if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`; const initialData =
const initialData = formData[tableSectionKey]; formData[`_tableSection_${sectionId}`] ||
formData[`__tableSection_${sectionId}`];
console.warn(`[TableSectionRenderer] 초기 데이터 로드 체크:`, {
sectionId,
hasUnderscoreData: !!formData[`_tableSection_${sectionId}`],
hasDoubleUnderscoreData: !!formData[`__tableSection_${sectionId}`],
dataLength: Array.isArray(initialData) ? initialData.length : "not array",
initialDataLoaded: initialDataLoadedRef.current,
});
if (Array.isArray(initialData) && initialData.length > 0) { if (Array.isArray(initialData) && initialData.length > 0) {
const conditionColumn = conditionalConfig?.conditionColumn; applyConditionalGrouping(initialData);
if (conditionColumn) {
// 조건별로 데이터 그룹핑
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of initialData) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
// 첫 번째 조건을 활성 탭으로 설정
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}
} }
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]); }, [isConditionalMode, sectionId, formData, applyConditionalGrouping]);
// 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드
const selfLoadAttemptedRef = React.useRef(false);
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
if (selfLoadAttemptedRef.current) return;
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
const linkColumn = editConfig?.linkColumn;
const targetTable = saveConfig?.targetTable;
console.warn(`[TableSectionRenderer] 자체 로드 체크:`, {
sectionId,
hasEditConfig: !!editConfig,
linkColumn,
targetTable,
masterField: linkColumn?.masterField,
masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A",
formDataKeys: Object.keys(formData).slice(0, 15),
initialDataLoaded: initialDataLoadedRef.current,
selfLoadAttempted: selfLoadAttemptedRef.current,
existingTableData_: !!formData[`_tableSection_${sectionId}`],
existingTableData__: !!formData[`__tableSection_${sectionId}`],
});
if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) {
console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`);
return;
}
const masterValue = formData[linkColumn.masterField];
if (!masterValue) {
console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`);
return;
}
// formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용
const existingData =
formData[`_tableSection_${sectionId}`] ||
formData[`__tableSection_${sectionId}`];
if (Array.isArray(existingData) && existingData.length > 0) {
console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}`);
applyConditionalGrouping(existingData);
return;
}
selfLoadAttemptedRef.current = true;
console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`);
const loadDetailData = async () => {
try {
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
search: {
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
},
page: 1,
size: 1000,
autoFilter: { enabled: true },
});
if (response.data?.success) {
let items: any[] = [];
const data = response.data.data;
if (Array.isArray(data)) items = data;
else if (data?.items && Array.isArray(data.items)) items = data.items;
else if (data?.rows && Array.isArray(data.rows)) items = data.rows;
else if (data?.data && Array.isArray(data.data)) items = data.data;
console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}`);
if (items.length > 0) {
applyConditionalGrouping(items);
}
} else {
console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data);
}
} catch (error) {
console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error);
}
};
loadDetailData();
}, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]);
// 조건부 테이블: 전체 항목 수 계산 // 조건부 테이블: 전체 항목 수 계산
const totalConditionalItems = useMemo(() => { const totalConditionalItems = useMemo(() => {

View File

@ -224,23 +224,38 @@ export function UniversalFormModalComponent({
// 설정 병합 // 설정 병합
const config: UniversalFormModalConfig = useMemo(() => { const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {}; const componentConfig = component?.config || {};
// V2 레이아웃에서 overrides 전체가 config로 전달되는 경우
// 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음
const nestedPropConfig = propConfig?.componentConfig;
const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined;
const effectivePropConfig = hasFlatPropConfig
? propConfig
: (nestedPropConfig?.modal ? nestedPropConfig : propConfig);
const nestedCompConfig = componentConfig?.componentConfig;
const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined;
const effectiveCompConfig = hasFlatCompConfig
? componentConfig
: (nestedCompConfig?.modal ? nestedCompConfig : componentConfig);
return { return {
...defaultConfig, ...defaultConfig,
...propConfig, ...effectivePropConfig,
...componentConfig, ...effectiveCompConfig,
modal: { modal: {
...defaultConfig.modal, ...defaultConfig.modal,
...propConfig?.modal, ...effectivePropConfig?.modal,
...componentConfig.modal, ...effectiveCompConfig?.modal,
}, },
saveConfig: { saveConfig: {
...defaultConfig.saveConfig, ...defaultConfig.saveConfig,
...propConfig?.saveConfig, ...effectivePropConfig?.saveConfig,
...componentConfig.saveConfig, ...effectiveCompConfig?.saveConfig,
afterSave: { afterSave: {
...defaultConfig.saveConfig.afterSave, ...defaultConfig.saveConfig.afterSave,
...propConfig?.saveConfig?.afterSave, ...effectivePropConfig?.saveConfig?.afterSave,
...componentConfig.saveConfig?.afterSave, ...effectiveCompConfig?.saveConfig?.afterSave,
}, },
}, },
}; };
@ -295,6 +310,7 @@ export function UniversalFormModalComponent({
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요) // 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined); const lastInitializedId = useRef<string | undefined>(undefined);
const tableSectionLoadedRef = useRef(false);
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => { useEffect(() => {
@ -316,7 +332,7 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) { if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) { if (!createModeDataHash || capturedInitialData.current) {
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨"); // console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString });
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요 // 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우) // (컴포넌트 remount로 인해 state가 초기화된 경우)
return; return;
@ -350,21 +366,13 @@ export function UniversalFormModalComponent({
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current); // console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
} }
// console.log("[UniversalFormModal] initializeForm 호출 예정"); // console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString });
hasInitialized.current = true; hasInitialized.current = true;
tableSectionLoadedRef.current = false;
initializeForm(); initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData]); // initialData 전체 변경 시 재초기화 }, [initialData]); // initialData 전체 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
// console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 컴포넌트 unmount 시 채번 플래그 초기화 // 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -728,9 +736,13 @@ export function UniversalFormModalComponent({
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조) // 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기 // 수정 모드일 때 디테일 테이블에서 데이터 가져오기
if (effectiveInitialData) { if (effectiveInitialData) {
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { // console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length });
sectionsCount: config.sections.length,
effectiveInitialDataKeys: Object.keys(effectiveInitialData), console.warn("[initializeForm] 테이블 섹션 순회 시작:", {
sectionCount: config.sections.length,
tableSections: config.sections.filter(s => s.type === "table").map(s => s.id),
hasInitialData: !!effectiveInitialData,
initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [],
}); });
for (const section of config.sections) { for (const section of config.sections) {
@ -739,16 +751,14 @@ export function UniversalFormModalComponent({
} }
const tableConfig = section.tableConfig; const tableConfig = section.tableConfig;
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
const editConfig = (tableConfig as any).editConfig; const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig; const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, { console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
hasEditConfig: !!editConfig, editConfig,
loadOnEdit: editConfig?.loadOnEdit,
hasSaveConfig: !!saveConfig,
targetTable: saveConfig?.targetTable, targetTable: saveConfig?.targetTable,
linkColumn: editConfig?.linkColumn, masterField: editConfig?.linkColumn?.masterField,
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
}); });
// 수정 모드 로드 설정 확인 (기본값: true) // 수정 모드 로드 설정 확인 (기본값: true)
@ -1073,6 +1083,25 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
// config 변경 시 테이블 섹션 데이터 로드 보완
// initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이
// 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음
useEffect(() => {
if (!hasInitialized.current) return;
const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable);
if (!hasTableSection) return;
const editData = capturedInitialData.current || initialData;
if (!editData || Object.keys(editData).length === 0) return;
if (tableSectionLoadedRef.current) return;
tableSectionLoadedRef.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections, initializeForm]);
// 반복 섹션 아이템 생성 // 반복 섹션 아이템 생성
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
const item: RepeatSectionItem = { const item: RepeatSectionItem = {

View File

@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({
onChange, onChange,
allComponents = [], allComponents = [],
}: UniversalFormModalConfigPanelProps) { }: UniversalFormModalConfigPanelProps) {
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용 // V2 레이아웃에서 overrides 전체가 componentConfig로 전달되는 경우
// 실제 설정이 rawConfig.componentConfig에 이중 중첩되어 있을 수 있음
// 평탄화된 구조(save 후)가 있으면 우선, 아니면 중첩 구조에서 추출
const nestedConfig = rawConfig?.componentConfig;
const hasFlatConfig = rawConfig?.modal !== undefined || rawConfig?.sections !== undefined;
const effectiveConfig = hasFlatConfig
? rawConfig
: (nestedConfig?.modal ? nestedConfig : rawConfig);
const config: UniversalFormModalConfig = { const config: UniversalFormModalConfig = {
...defaultConfig, ...defaultConfig,
...rawConfig, ...effectiveConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal }, modal: { ...defaultConfig.modal, ...effectiveConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections, sections: effectiveConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig }, saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode }, editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode },
}; };
// 테이블 목록 // 테이블 목록

View File

@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({
}; };
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => { const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
updateTableConfig({ const newUiConfig = { ...tableConfig.uiConfig, ...updates };
uiConfig: { ...tableConfig.uiConfig, ...updates }, // 새 버튼 설정이 사용되면 레거시 addButtonType 제거
}); if ("showSearchButton" in updates || "showAddRowButton" in updates) {
delete (newUiConfig as any).addButtonType;
}
updateTableConfig({ uiConfig: newUiConfig });
}; };
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => { const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {

View File

@ -20,6 +20,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
@ -1069,7 +1070,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 데이터 전달 실패:", error); console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); showErrorToast("데이터 전달에 실패했습니다", error, { guidance: "대상 화면 설정과 데이터를 확인해 주세요." });
} }
}; };

View File

@ -114,7 +114,14 @@ export function DetailFormModal({
if (type === "input" && !formData.content?.trim()) return; if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return; if (type === "info" && !formData.lookup_target) return;
onSubmit(formData); const submitData = { ...formData };
if (type === "info" && !submitData.content?.trim()) {
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
submitData.content = `${targetLabel} 조회`;
}
onSubmit(submitData);
onClose(); onClose();
}; };

View File

@ -21,6 +21,7 @@ interface V2RepeaterRendererProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
parentId?: string | number; parentId?: string | number;
formData?: Record<string, any>; formData?: Record<string, any>;
groupedData?: Record<string, any>[];
} }
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick, onButtonClick,
parentId, parentId,
formData, formData,
groupedData,
}) => { }) => {
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출 // component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
const config: V2RepeaterConfig = React.useMemo(() => { const config: V2RepeaterConfig = React.useMemo(() => {
@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
className={component?.className} className={component?.className}
formData={formData} formData={formData}
groupedData={groupedData}
/> />
); );
}; };

View File

@ -181,6 +181,7 @@ import { FileText, ChevronRightIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { tableDisplayStore } from "@/stores/tableDisplayStore"; import { tableDisplayStore } from "@/stores/tableDisplayStore";
import { import {
Dialog, Dialog,
@ -2577,7 +2578,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`);
} catch (error) { } catch (error) {
toast.error("저장 중 오류가 발생했습니다."); showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
} }
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
@ -2765,7 +2766,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
} catch (error) { } catch (error) {
toast.error("Excel 내보내기 중 오류가 발생했습니다."); showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, },
[ [
@ -3216,7 +3217,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
toast.success(`${copyData.length}행 복사됨`); toast.success(`${copyData.length}행 복사됨`);
} catch (error) { } catch (error) {
toast.error("복사 실패"); showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." });
} }
}, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]);
@ -3771,7 +3772,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
} catch (error) { } catch (error) {
console.error("❌ PDF 내보내기 실패:", error); console.error("❌ PDF 내보내기 실패:", error);
toast.error("PDF 내보내기 중 오류가 발생했습니다."); showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." });
} }
}, },
[ [
@ -4519,7 +4520,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSearchValues({}); setSearchValues({});
} catch (error) { } catch (error) {
console.error("필터 설정 저장 실패:", error); console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다"); showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
}, [filterSettingKey, visibleFilterColumns]); }, [filterSettingKey, visibleFilterColumns]);

View File

@ -21,6 +21,7 @@ import { dataApi } from "@/lib/api/data";
import { executePopAction } from "@/hooks/pop/executePopAction"; import { executePopAction } from "@/hooks/pop/executePopAction";
import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import type { import type {
PopStringListConfig, PopStringListConfig,
CardGridConfig, CardGridConfig,
@ -146,10 +147,10 @@ export function PopStringListComponent({
if (result.success) { if (result.success) {
toast.success("작업이 완료되었습니다."); toast.success("작업이 완료되었습니다.");
} else { } else {
toast.error(result.error || "작업에 실패했습니다."); showErrorToast("작업에 실패했습니다", result.error, { guidance: "잠시 후 다시 시도해 주세요." });
} }
} catch { } catch {
toast.error("알 수 없는 오류가 발생했습니다."); showErrorToast("예기치 않은 오류가 발생했습니다", null, { guidance: "잠시 후 다시 시도해 주세요." });
} finally { } finally {
setLoadingRowIdx(-1); setLoadingRowIdx(-1);
} }

View File

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor"; import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
@ -456,7 +457,11 @@ export class ButtonActionExecutor {
} }
} catch (error) { } catch (error) {
console.error("버튼 액션 실행 오류:", error); console.error("버튼 액션 실행 오류:", error);
toast.error(config.errorMessage || "작업 중 오류가 발생했습니다."); showErrorToast(
config.errorMessage || `'${config.label || config.type}' 버튼 실행에 실패했습니다`,
error,
{ guidance: "설정을 확인하거나 잠시 후 다시 시도해 주세요." }
);
return false; return false;
} }
} }
@ -2652,7 +2657,9 @@ export class ButtonActionExecutor {
return { handled: true, success: true }; return { handled: true, success: true };
} catch (error: any) { } catch (error: any) {
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error); console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
toast.error(error.message || "저장 중 오류가 발생했습니다."); showErrorToast("테이블 섹션 데이터 저장에 실패했습니다", error, {
guidance: "입력값을 확인하고 다시 시도해 주세요.",
});
return { handled: true, success: false }; return { handled: true, success: false };
} }
} }
@ -2894,7 +2901,9 @@ export class ButtonActionExecutor {
if (failCount === 0) { if (failCount === 0) {
toast.success(`${successCount}개 항목이 저장되었습니다.`); toast.success(`${successCount}개 항목이 저장되었습니다.`);
} else if (successCount === 0) { } else if (successCount === 0) {
toast.error(`저장 실패: ${errors.join(", ")}`); showErrorToast(`${errors.length}개 항목 저장에 모두 실패했습니다`, errors.join("\n"), {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
return false; return false;
} else { } else {
toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`); toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
@ -2911,7 +2920,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error("배치 저장 오류:", error); console.error("배치 저장 오류:", error);
toast.error(`저장 오류: ${error.message}`); showErrorToast("배치 저장 중 오류가 발생했습니다", error, {
guidance: "저장 대상 데이터를 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -3263,7 +3274,9 @@ export class ButtonActionExecutor {
} }
} catch (error) { } catch (error) {
console.error("❌ 데이터 확인 실패:", error); console.error("❌ 데이터 확인 실패:", error);
toast.error("데이터 확인 중 오류가 발생했습니다."); showErrorToast("상위 데이터 조회에 실패했습니다", error, {
guidance: "데이터 소스 연결 상태를 확인해 주세요.",
});
return false; return false;
} }
} else { } else {
@ -3436,7 +3449,9 @@ export class ButtonActionExecutor {
} }
} catch (error) { } catch (error) {
console.error("❌ 데이터 확인 실패:", error); console.error("❌ 데이터 확인 실패:", error);
toast.error("데이터 확인 중 오류가 발생했습니다."); showErrorToast("모달 데이터 확인에 실패했습니다", error, {
guidance: "데이터 소스를 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
@ -3997,7 +4012,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error("❌ 복사 액션 실행 중 오류:", error); console.error("❌ 복사 액션 실행 중 오류:", error);
toast.error(`복사 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`); showErrorToast("데이터 복사에 실패했습니다", error, {
guidance: "복사 대상 데이터를 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -4228,12 +4245,18 @@ export class ButtonActionExecutor {
return true; return true;
} else { } else {
console.error("❌ 노드 플로우 실행 실패:", result); console.error("❌ 노드 플로우 실행 실패:", result);
toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다."); showErrorToast(
config.errorMessage || "플로우 실행에 실패했습니다",
result.message,
{ guidance: "플로우 설정과 데이터를 확인해 주세요." }
);
return false; return false;
} }
} catch (error) { } catch (error) {
console.error("❌ 노드 플로우 실행 오류:", error); console.error("❌ 노드 플로우 실행 오류:", error);
toast.error("플로우 실행 중 오류가 발생했습니다."); showErrorToast("플로우 실행 중 오류가 발생했습니다", error, {
guidance: "플로우 연결 상태와 데이터를 확인해 주세요.",
});
return false; return false;
} }
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) { } else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
@ -4277,7 +4300,11 @@ export class ButtonActionExecutor {
return true; return true;
} else { } else {
console.error("❌ 관계 실행 실패:", executionResult); console.error("❌ 관계 실행 실패:", executionResult);
toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다."); showErrorToast(
config.errorMessage || "관계 실행에 실패했습니다",
executionResult.message || executionResult.error,
{ guidance: "관계 설정과 데이터를 확인해 주세요." }
);
return false; return false;
} }
} else { } else {
@ -4292,7 +4319,9 @@ export class ButtonActionExecutor {
} }
} catch (error) { } catch (error) {
console.error("제어 조건 검증 중 오류:", error); console.error("제어 조건 검증 중 오류:", error);
toast.error("제어 조건 검증 중 오류가 발생했습니다."); showErrorToast("제어 조건 검증에 실패했습니다", error, {
guidance: "제어 설정을 확인해 주세요.",
});
return false; return false;
} }
} }
@ -4420,7 +4449,12 @@ export class ButtonActionExecutor {
if (allSuccess) { if (allSuccess) {
toast.success(`${successCount}개 제어 실행 완료`); toast.success(`${successCount}개 제어 실행 완료`);
} else { } else {
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`); const failedNames = results.filter((r) => !r.success).map((r) => r.flowName || r.flowId).join(", ");
showErrorToast(
`제어 실행 중 일부 실패 (${successCount}/${results.length} 성공)`,
failedNames ? `실패 항목: ${failedNames}` : undefined,
{ guidance: "실패한 제어 플로우 설정을 확인해 주세요." }
);
} }
return; return;
@ -4486,11 +4520,15 @@ export class ButtonActionExecutor {
toast.success("제어 로직 실행이 완료되었습니다."); toast.success("제어 로직 실행이 완료되었습니다.");
} else { } else {
console.error("❌ 저장 후 노드 플로우 실행 실패:", result); console.error("❌ 저장 후 노드 플로우 실행 실패:", result);
toast.error("저장은 완료되었으나 제어 실행 중 오류가 발생했습니다."); showErrorToast("저장은 완료되었으나 후속 제어 실행에 실패했습니다", result.message, {
guidance: "제어 플로우 설정을 확인해 주세요. 데이터는 정상 저장되었습니다.",
});
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 저장 후 노드 플로우 실행 오류:", error); console.error("❌ 저장 후 노드 플로우 실행 오류:", error);
toast.error(`제어 실행 오류: ${error.message || "알 수 없는 오류"}`); showErrorToast("저장은 완료되었으나 후속 제어 실행에 실패했습니다", error, {
guidance: "제어 플로우 설정을 확인해 주세요. 데이터는 정상 저장되었습니다.",
});
} }
return; // 노드 플로우 실행 후 종료 return; // 노드 플로우 실행 후 종료
@ -4521,7 +4559,9 @@ export class ButtonActionExecutor {
// 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음 // 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음
} else { } else {
console.error("❌ 저장 후 제어 실행 실패:", executionResult); console.error("❌ 저장 후 제어 실행 실패:", executionResult);
toast.error("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); showErrorToast("저장은 완료되었으나 연결된 제어 실행에 실패했습니다", executionResult.message || executionResult.error, {
guidance: "제어 관계 설정을 확인해 주세요. 데이터는 정상 저장되었습니다.",
});
} }
} }
} }
@ -4565,9 +4605,10 @@ export class ButtonActionExecutor {
const actionType = action.actionType || action.type; const actionType = action.actionType || action.type;
console.error(`❌ 액션 ${i + 1}/${actions.length} 실행 실패:`, action.name, error); console.error(`❌ 액션 ${i + 1}/${actions.length} 실행 실패:`, action.name, error);
// 실패 토스트 showErrorToast(
toast.error( `'${action.name || `액션 ${i + 1}`}' 실행에 실패했습니다`,
`${action.name || `액션 ${i + 1}`} 실행 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, error,
{ guidance: `전체 ${actions.length}개 액션 중 ${i + 1}번째에서 중단되었습니다.` }
); );
// 🚨 순차 실행 중단: 하나라도 실패하면 전체 중단 // 🚨 순차 실행 중단: 하나라도 실패하면 전체 중단
@ -4635,9 +4676,11 @@ export class ButtonActionExecutor {
} else { } else {
throw new Error(result.message || "저장 실패"); throw new Error(result.message || "저장 실패");
} }
} catch (error) { } catch (error: any) {
console.error("❌ 저장 실패:", error); console.error("❌ 저장 실패:", error);
toast.error(`저장 실패: ${error.message}`); showErrorToast(`'${context.tableName}' 테이블 저장에 실패했습니다`, error, {
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
throw error; throw error;
} }
} }
@ -4724,9 +4767,11 @@ export class ButtonActionExecutor {
} else { } else {
throw new Error(result.message || "업데이트 실패"); throw new Error(result.message || "업데이트 실패");
} }
} catch (error) { } catch (error: any) {
console.error("❌ 업데이트 실패:", error); console.error("❌ 업데이트 실패:", error);
toast.error(`업데이트 실패: ${error.message}`); showErrorToast(`'${context.tableName}' 데이터 수정에 실패했습니다`, error, {
guidance: "수정 데이터를 확인하고 다시 시도해 주세요.",
});
throw error; throw error;
} }
} }
@ -4780,9 +4825,11 @@ export class ButtonActionExecutor {
} else { } else {
throw new Error(result.message || "삭제 실패"); throw new Error(result.message || "삭제 실패");
} }
} catch (error) { } catch (error: any) {
console.error("❌ 삭제 실패:", error); console.error("❌ 삭제 실패:", error);
toast.error(`삭제 실패: ${error.message}`); showErrorToast(`'${context.tableName}' 데이터 삭제에 실패했습니다`, error, {
guidance: "삭제 대상을 확인하고 다시 시도해 주세요.",
});
throw error; throw error;
} }
} }
@ -4863,7 +4910,9 @@ export class ButtonActionExecutor {
} }
} catch (error) { } catch (error) {
console.error("❌ 삽입 실패:", error); console.error("❌ 삽입 실패:", error);
toast.error(`삽입 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); showErrorToast("데이터 삽입에 실패했습니다", error, {
guidance: "필수 입력 항목과 데이터 형식을 확인해 주세요.",
});
throw error; throw error;
} }
} }
@ -4964,7 +5013,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ 이력 모달 열기 실패:", error); console.error("❌ 이력 모달 열기 실패:", error);
toast.error("이력 조회 중 오류가 발생했습니다."); showErrorToast("이력 조회에 실패했습니다", error, {
guidance: "이력 테이블 설정을 확인해 주세요.",
});
return false; return false;
} }
} }
@ -5004,7 +5055,9 @@ export class ButtonActionExecutor {
columnLabels![col] = downloadResponse.data.headers[index] || col; columnLabels![col] = downloadResponse.data.headers[index] || col;
}); });
} else { } else {
toast.error("마스터-디테일 데이터 조회에 실패했습니다."); showErrorToast("마스터-디테일 데이터 조회에 실패했습니다", null, {
guidance: "데이터 소스 설정을 확인해 주세요.",
});
return false; return false;
} }
@ -5094,12 +5147,16 @@ export class ButtonActionExecutor {
dataToExport = response.data; dataToExport = response.data;
} else { } else {
console.error("❌ 예상치 못한 응답 형식:", response); console.error("❌ 예상치 못한 응답 형식:", response);
toast.error("데이터를 가져오는데 실패했습니다."); showErrorToast("엑셀 데이터 조회에 실패했습니다", null, {
guidance: "서버 응답 형식이 예상과 다릅니다. 관리자에게 문의해 주세요.",
});
return false; return false;
} }
} catch (error) { } catch (error) {
console.error("엑셀 다운로드: 데이터 조회 실패:", error); console.error("엑셀 다운로드: 데이터 조회 실패:", error);
toast.error("데이터를 가져오는데 실패했습니다."); showErrorToast("엑셀 다운로드용 데이터 조회에 실패했습니다", error, {
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -5109,7 +5166,9 @@ export class ButtonActionExecutor {
} }
// 테이블명도 없고 폼 데이터도 없으면 에러 // 테이블명도 없고 폼 데이터도 없으면 에러
else { else {
toast.error("다운로드할 데이터 소스가 없습니다."); toast.error("다운로드할 데이터 소스가 없습니다.", {
description: "테이블 또는 폼 데이터가 설정되어 있지 않습니다. 버튼 설정을 확인해 주세요.",
});
return false; return false;
} }
@ -5118,13 +5177,17 @@ export class ButtonActionExecutor {
if (typeof dataToExport === "object" && dataToExport !== null) { if (typeof dataToExport === "object" && dataToExport !== null) {
dataToExport = [dataToExport]; dataToExport = [dataToExport];
} else { } else {
toast.error("다운로드할 데이터 형식이 올바르지 않습니다."); toast.error("다운로드할 데이터 형식이 올바르지 않습니다.", {
description: "서버에서 받은 데이터 형식이 예상과 다릅니다. 관리자에게 문의해 주세요.",
});
return false; return false;
} }
} }
if (dataToExport.length === 0) { if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다."); toast.error("다운로드할 데이터가 없습니다.", {
description: "조회 조건에 맞는 데이터가 없습니다. 검색 조건을 변경해 보세요.",
});
return false; return false;
} }
@ -5399,7 +5462,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ 엑셀 다운로드 실패:", error); console.error("❌ 엑셀 다운로드 실패:", error);
toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "엑셀 파일 다운로드에 실패했습니다", error, {
guidance: "데이터를 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -5503,7 +5568,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ 엑셀 업로드 모달 열기 실패:", error); console.error("❌ 엑셀 업로드 모달 열기 실패:", error);
toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "엑셀 업로드 화면을 열 수 없습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -5564,7 +5631,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ 바코드 스캔 모달 열기 실패:", error); console.error("❌ 바코드 스캔 모달 열기 실패:", error);
toast.error("바코드 스캔 중 오류가 발생했습니다."); showErrorToast("바코드 스캔 화면을 열 수 없습니다", error, {
guidance: "카메라 권한을 확인하고 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -5744,13 +5813,15 @@ export class ButtonActionExecutor {
return true; return true;
} else { } else {
toast.error(response.data.message || "코드 병합에 실패했습니다."); showErrorToast("코드 병합에 실패했습니다", response.data.message, {
guidance: "병합 대상 코드를 확인해 주세요.",
});
return false; return false;
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 코드 병합 실패:", error); console.error("❌ 코드 병합 실패:", error);
toast.dismiss(); toast.dismiss();
toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다."); showErrorToast("코드 병합 중 오류가 발생했습니다", error.response?.data?.message || error);
return false; return false;
} }
} }
@ -5877,7 +5948,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error("❌ 위치 추적 시작 실패:", error); console.error("❌ 위치 추적 시작 실패:", error);
toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "위치 추적을 시작할 수 없습니다", error, {
guidance: "위치 권한 설정과 GPS 상태를 확인해 주세요.",
});
return false; return false;
} }
} }
@ -6131,7 +6204,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error("❌ 위치 추적 종료 실패:", error); console.error("❌ 위치 추적 종료 실패:", error);
toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "위치 추적 종료에 실패했습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
return false; return false;
} }
} }
@ -6425,7 +6500,9 @@ export class ButtonActionExecutor {
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ 데이터 전달 실패:", error); console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); showErrorToast("데이터 전달에 실패했습니다", error, {
guidance: "대상 화면 설정과 데이터를 확인해 주세요.",
});
return false; return false;
} }
} }
@ -6555,7 +6632,9 @@ export class ButtonActionExecutor {
toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다.");
} catch (saveError) { } catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError); console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다."); showErrorToast("위치 정보 저장에 실패했습니다", saveError, {
guidance: "네트워크 연결을 확인해 주세요.",
});
return false; return false;
} }
} else { } else {
@ -6585,10 +6664,14 @@ export class ButtonActionExecutor {
toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요."); toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요.");
break; break;
default: default:
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "위치 정보를 가져올 수 없습니다", null, {
guidance: "브라우저 설정에서 위치 권한을 확인해 주세요.",
});
} }
} else { } else {
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "위치 정보를 가져올 수 없습니다", error, {
guidance: "브라우저 설정에서 위치 권한을 확인해 주세요.",
});
} }
return false; return false;
@ -6760,7 +6843,9 @@ export class ButtonActionExecutor {
return true; return true;
} catch (error) { } catch (error) {
console.error("❌ 필드 값 교환 오류:", error); console.error("❌ 필드 값 교환 오류:", error);
toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다."); showErrorToast(config.errorMessage || "필드 값 교환에 실패했습니다", error, {
guidance: "교환 대상 필드 설정을 확인해 주세요.",
});
return false; return false;
} }
} }

View File

@ -8,6 +8,7 @@
*/ */
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { ExtendedButtonTypeConfig, ButtonDataflowConfig } from "@/types/control-management"; import { ExtendedButtonTypeConfig, ButtonDataflowConfig } from "@/types/control-management";
import { ButtonActionType } from "@/types/v2-core"; import { ButtonActionType } from "@/types/v2-core";
@ -383,7 +384,9 @@ export class ImprovedButtonActionExecutor {
if (result.success) { if (result.success) {
toast.success(`관계 '${config.relationshipName}' 실행 완료`); toast.success(`관계 '${config.relationshipName}' 실행 완료`);
} else { } else {
toast.error(`관계 '${config.relationshipName}' 실행 실패: ${result.message}`); showErrorToast(`관계 '${config.relationshipName}' 실행에 실패했습니다`, result.message, {
guidance: "관계 설정과 대상 데이터를 확인해 주세요.",
});
} }
return result; return result;
@ -396,7 +399,9 @@ export class ImprovedButtonActionExecutor {
error: error.message, error: error.message,
}; };
toast.error(errorResult.message); showErrorToast(`관계 '${config.relationshipName}' 실행에 실패했습니다`, error, {
guidance: "관계 설정과 연결 상태를 확인해 주세요.",
});
return errorResult; return errorResult;
} }
} }
@ -1057,7 +1062,8 @@ export class ImprovedButtonActionExecutor {
} }
} }
// 오류 토스트 표시 showErrorToast("버튼 액션 실행 중 오류가 발생했습니다", error, {
toast.error(error.message || "작업 중 오류가 발생했습니다."); guidance: "잠시 후 다시 시도해 주세요. 문제가 계속되면 관리자에게 문의해 주세요.",
});
} }
} }

View File

@ -0,0 +1,82 @@
import { toast } from "sonner";
/**
* /catch
*/
function extractErrorMessage(error: unknown): string | null {
if (!error) return null;
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (typeof error === "object" && error !== null) {
const obj = error as Record<string, any>;
return (
obj.response?.data?.message ||
obj.response?.data?.error ||
obj.message ||
obj.error ||
null
);
}
return null;
}
/**
* .
*
* @param title - (: "메뉴 저장에 실패했습니다")
* @param error - catch error
* @param options -
* @param options.guidance - (: "네트워크 연결을 확인해 주세요")
* @param options.duration - (ms)
*/
export function showErrorToast(
title: string,
error?: unknown,
options?: {
guidance?: string;
duration?: number;
}
) {
const errorMessage = extractErrorMessage(error);
const guidance = options?.guidance;
const descriptionParts: string[] = [];
if (errorMessage) descriptionParts.push(errorMessage);
if (guidance) descriptionParts.push(guidance);
const description =
descriptionParts.length > 0 ? descriptionParts.join("\n") : undefined;
toast.error(title, {
description,
duration: options?.duration || 5000,
});
}
/**
* API
* API .
*/
export function showApiErrorToast(
action: string,
response?: { message?: string; error?: string } | null,
fallbackError?: unknown
) {
const apiMessage = response?.message || response?.error;
const errorMessage = apiMessage || extractErrorMessage(fallbackError);
const description = errorMessage || "잠시 후 다시 시도해 주세요.";
toast.error(`${action}에 실패했습니다`, {
description,
duration: 5000,
});
}

View File

@ -13,6 +13,7 @@ import { V2_EVENTS } from "../events/types";
import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types"; import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
// ============================================================================ // ============================================================================
// 타입 정의 // 타입 정의
@ -230,7 +231,8 @@ export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig |
}); });
} catch (error: any) { } catch (error: any) {
console.error("[ScheduleGeneratorService] 미리보기 오류:", error); console.error("[ScheduleGeneratorService] 미리보기 오류:", error);
toast.error("스케줄 생성 중 오류가 발생했습니다.", { id: "schedule-generate" }); toast.dismiss("schedule-generate");
showErrorToast("스케줄 미리보기 생성에 실패했습니다", error, { guidance: "스케줄 설정을 확인하고 다시 시도해 주세요." });
v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, { v2EventBus.emit(V2_EVENTS.SCHEDULE_GENERATE_ERROR, {
requestId: payload.requestId, requestId: payload.requestId,
error: error.message, error: error.message,
@ -295,7 +297,8 @@ export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig |
setPreviewResult(null); setPreviewResult(null);
} catch (error: any) { } catch (error: any) {
console.error("[ScheduleGeneratorService] 적용 오류:", error); console.error("[ScheduleGeneratorService] 적용 오류:", error);
toast.error("스케줄 적용 중 오류가 발생했습니다.", { id: "schedule-apply" }); toast.dismiss("schedule-apply");
showErrorToast("스케줄 적용에 실패했습니다", error, { guidance: "스케줄 설정과 데이터를 확인하고 다시 시도해 주세요." });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -235,6 +235,7 @@ export interface FlowStepDataList {
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;
@ -268,6 +269,8 @@ export interface FlowNodeData {
tableName?: string; tableName?: string;
count?: number; count?: number;
condition?: FlowConditionGroup; condition?: FlowConditionGroup;
integrationType?: string;
procedureName?: string;
} }
export interface FlowEdgeData { export interface FlowEdgeData {

View File

@ -5,7 +5,7 @@
// ==================== 연동 타입 ==================== // ==================== 연동 타입 ====================
export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "webhook" | "hybrid"; export type FlowIntegrationType = "internal" | "external_db" | "procedure" | "rest_api" | "webhook" | "hybrid";
// ==================== 외부 DB 연결 ==================== // ==================== 외부 DB 연결 ====================
@ -66,8 +66,48 @@ export interface FlowExternalDbIntegrationConfig {
customQuery?: string; // 커스텀 쿼리 customQuery?: string; // 커스텀 쿼리
} }
// 프로시저 호출 파라미터 정의
export interface FlowProcedureParam {
name: string;
dataType: string;
mode: "IN" | "OUT" | "INOUT";
source: "record_field" | "static" | "step_variable";
field?: string;
value?: string;
}
// 프로시저 호출 설정
export interface FlowProcedureConfig {
type: "procedure";
dbSource: "internal" | "external";
connectionId?: number;
procedureName: string;
procedureSchema?: string;
callType: "procedure" | "function";
parameters: FlowProcedureParam[];
}
// 프로시저/함수 목록 항목
export interface ProcedureListItem {
name: string;
schema: string;
type: "PROCEDURE" | "FUNCTION";
returnType?: string;
}
// 프로시저 파라미터 정보
export interface ProcedureParameterInfo {
name: string;
position: number;
dataType: string;
mode: "IN" | "OUT" | "INOUT";
defaultValue?: string;
}
// 연동 설정 통합 타입 // 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; export type FlowIntegrationConfig =
| FlowExternalDbIntegrationConfig
| FlowProcedureConfig;
// ==================== 연동 로그 ==================== // ==================== 연동 로그 ====================
@ -126,6 +166,7 @@ export const OPERATION_OPTIONS = [
export const INTEGRATION_TYPE_OPTIONS = [ export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" }, { value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" }, { value: "external_db", label: "외부 DB 연동" },
{ value: "procedure", label: "프로시저/함수 호출" },
{ value: "rest_api", label: "REST API 연동" }, { value: "rest_api", label: "REST API 연동" },
{ value: "webhook", label: "Webhook (추후 지원)" }, { value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" }, { value: "hybrid", label: "복합 연동 (추후 지원)" },

View File

@ -23,6 +23,9 @@ export interface MenuItem {
// 계층적 메뉴 구조를 위한 필드들 // 계층적 메뉴 구조를 위한 필드들
children?: MenuItem[]; children?: MenuItem[];
// 아이콘 필드
menu_icon?: string;
// 번역 관련 필드들 // 번역 관련 필드들
translated_name?: string; translated_name?: string;
translated_desc?: string; translated_desc?: string;
@ -47,6 +50,9 @@ export interface MenuItem {
COMPANY_CODE?: string; COMPANY_CODE?: string;
COMPANY_NAME?: string; COMPANY_NAME?: string;
// 아이콘 대문자 키
MENU_ICON?: string;
// 번역 관련 대문자 키들 // 번역 관련 대문자 키들
TRANSLATED_NAME?: string; TRANSLATED_NAME?: string;
TRANSLATED_DESC?: string; TRANSLATED_DESC?: string;

View File

@ -23,6 +23,7 @@ export type NodeType =
| "emailAction" // 메일 발송 액션 | "emailAction" // 메일 발송 액션
| "scriptAction" // 스크립트 실행 액션 | "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션 | "httpRequestAction" // HTTP 요청 액션
| "procedureCallAction" // 프로시저/함수 호출 액션
| "comment" // 주석 | "comment" // 주석
| "log"; // 로그 | "log"; // 로그
@ -705,6 +706,31 @@ export interface HttpRequestActionNodeData {
}; };
} }
// ============================================================================
// 프로시저/함수 호출 노드
// ============================================================================
export interface ProcedureCallActionNodeData {
displayName?: string;
dbSource: "internal" | "external";
connectionId?: number;
connectionName?: string;
procedureName?: string;
procedureSchema?: string;
callType: "procedure" | "function";
parameters?: {
name: string;
dataType: string;
mode: "IN" | "OUT" | "INOUT";
source: "record_field" | "static" | "step_variable";
field?: string;
value?: string;
}[];
}
// ============================================================================ // ============================================================================
// 통합 노드 데이터 타입 // 통합 노드 데이터 타입
// ============================================================================ // ============================================================================
@ -725,6 +751,7 @@ export type NodeData =
| EmailActionNodeData | EmailActionNodeData
| ScriptActionNodeData | ScriptActionNodeData
| HttpRequestActionNodeData | HttpRequestActionNodeData
| ProcedureCallActionNodeData
| CommentNodeData | CommentNodeData
| LogNodeData; | LogNodeData;

View File

@ -50,11 +50,13 @@ export interface RepeaterColumnConfig {
width: ColumnWidthOption; width: ColumnWidthOption;
visible: boolean; visible: boolean;
editable?: boolean; // 편집 가능 여부 (inline 모드) editable?: boolean; // 편집 가능 여부 (inline 모드)
hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨) hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨)
isJoinColumn?: boolean; isJoinColumn?: boolean;
sourceTable?: string; sourceTable?: string;
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시) // 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
isSourceDisplay?: boolean; isSourceDisplay?: boolean;
// 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty)
sourceKey?: string;
// 입력 타입 (테이블 타입 관리의 inputType을 따름) // 입력 타입 (테이블 타입 관리의 inputType을 따름)
inputType?: string; // text, number, date, code, entity 등 inputType?: string; // text, number, date, code, entity 등
// 🆕 자동 입력 설정 // 🆕 자동 입력 설정
@ -140,6 +142,20 @@ export interface CalculationRule {
label?: string; label?: string;
} }
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
export interface SourceDetailConfig {
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
foreignKey: string; // 디테일 테이블의 FK 컬럼 (예: "order_no")
parentKey: string; // 전달받은 마스터 데이터에서 추출할 키 (예: "order_no")
useEntityJoin?: boolean; // 엔티티 조인 사용 여부 (data-with-joins API)
columnMapping?: Record<string, string>; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" })
additionalJoinColumns?: Array<{
sourceColumn: string;
sourceTable: string;
joinAlias: string;
}>;
}
// 메인 설정 타입 // 메인 설정 타입
export interface V2RepeaterConfig { export interface V2RepeaterConfig {
// 렌더링 모드 // 렌더링 모드
@ -151,6 +167,9 @@ export interface V2RepeaterConfig {
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id) foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용 foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
// 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드)
sourceDetailConfig?: SourceDetailConfig;
// 데이터 소스 설정 // 데이터 소스 설정
dataSource: RepeaterDataSource; dataSource: RepeaterDataSource;
@ -189,6 +208,7 @@ export interface V2RepeaterProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
className?: string; className?: string;
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용 formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
groupedData?: Record<string, any>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
} }
// 기본 설정값 // 기본 설정값