Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
d7ef26d679
|
|
@ -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),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 || "프로시저 파라미터 조회에 실패했습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 '활성화'
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건 검증
|
* 조건 검증
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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과 설정을 확인해 주세요.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "배치 설정을 확인해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "수집 설정을 확인해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: "네트워크 연결을 확인해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "파일이 존재하는지 확인하고 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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("업로드에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: "브라우저 권한을 확인해 주세요." });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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: "로그",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
// 🔥 외부 커넥션 로딩 함수
|
// 🔥 외부 커넥션 로딩 함수
|
||||||
|
|
|
||||||
|
|
@ -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", // 보라색
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
|
|
|
||||||
|
|
@ -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}건의 데이터를 다음 단계로 이동했습니다`);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" />)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공 시 후속 액션 실행
|
// 성공 시 후속 액션 실행
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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: "대상 화면 설정과 데이터를 확인해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
||||||
// 카테고리 컬럼이 아니면 원래 값 반환
|
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 && result.success && Array.isArray(result.data)) {
|
if (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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블 목록
|
// 테이블 목록
|
||||||
|
|
|
||||||
|
|
@ -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"]>>) => {
|
||||||
|
|
|
||||||
|
|
@ -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: "대상 화면 설정과 데이터를 확인해 주세요." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "잠시 후 다시 시도해 주세요. 문제가 계속되면 관리자에게 문의해 주세요.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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: "복합 연동 (추후 지원)" },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue