외부 호출 중간 저장

This commit is contained in:
hyeonsu 2025-09-17 17:14:59 +09:00
parent f85aac65db
commit b1a3ba713a
11 changed files with 1972 additions and 50 deletions

View File

@ -20,6 +20,25 @@ model dynamic_form_data {
company_code String @db.VarChar(20)
}
model external_call_configs {
id Int @id @default(autoincrement())
config_name String @db.VarChar(100)
call_type String @db.VarChar(20)
api_type String? @db.VarChar(20)
config_data Json
description String? @db.Text
company_code String @default("*") @db.VarChar(20)
is_active String @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
updated_by String? @db.VarChar(50)
@@index([company_code])
@@index([call_type, api_type])
@@index([is_active])
}
model admin_supply_mng {
objid Decimal @id @default(0) @db.Decimal
supply_code String? @default("NULL::character varying") @db.VarChar(100)

View File

@ -29,6 +29,7 @@ import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -121,6 +122,7 @@ app.use("/api/layouts", layoutRoutes);
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -0,0 +1,252 @@
import express, { Request, Response } from "express";
import externalCallConfigService, {
ExternalCallConfigFilter,
} from "../services/externalCallConfigService";
import { logger } from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
*
* GET /api/external-call-configs
*/
router.get("/", async (req: Request, res: Response) => {
try {
const filter: ExternalCallConfigFilter = {
company_code: req.query.company_code as string,
call_type: req.query.call_type as string,
api_type: req.query.api_type as string,
is_active: (req.query.is_active as string) || "Y",
search: req.query.search as string,
};
const configs = await externalCallConfigService.getConfigs(filter);
return res.json({
success: true,
data: configs,
message: `외부 호출 설정 ${configs.length}개 조회 완료`,
});
} catch (error) {
logger.error("외부 호출 설정 목록 조회 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error
? error.message
: "외부 호출 설정 목록 조회 실패",
errorCode: "EXTERNAL_CALL_CONFIG_LIST_ERROR",
});
}
});
/**
*
* GET /api/external-call-configs/:id
*/
router.get("/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
const config = await externalCallConfigService.getConfigById(id);
if (!config) {
return res.status(404).json({
success: false,
message: "외부 호출 설정을 찾을 수 없습니다.",
errorCode: "CONFIG_NOT_FOUND",
});
}
return res.json({
success: true,
data: config,
message: "외부 호출 설정 조회 완료",
});
} catch (error) {
logger.error("외부 호출 설정 조회 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "외부 호출 설정 조회 실패",
errorCode: "EXTERNAL_CALL_CONFIG_GET_ERROR",
});
}
});
/**
*
* POST /api/external-call-configs
*/
router.post("/", async (req: Request, res: Response) => {
try {
const {
config_name,
call_type,
api_type,
config_data,
description,
company_code,
} = req.body;
// 필수 필드 검증
if (!config_name || !call_type || !config_data) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (config_name, call_type, config_data)",
errorCode: "MISSING_REQUIRED_FIELDS",
});
}
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
const newConfig = await externalCallConfigService.createConfig({
config_name,
call_type,
api_type,
config_data,
description,
company_code: company_code || "*",
created_by: userId,
updated_by: userId,
});
return res.status(201).json({
success: true,
data: newConfig,
message: "외부 호출 설정이 성공적으로 생성되었습니다.",
});
} catch (error) {
logger.error("외부 호출 설정 생성 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "외부 호출 설정 생성 실패",
errorCode: "EXTERNAL_CALL_CONFIG_CREATE_ERROR",
});
}
});
/**
*
* PUT /api/external-call-configs/:id
*/
router.put("/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
const updatedConfig = await externalCallConfigService.updateConfig(id, {
...req.body,
updated_by: userId,
});
return res.json({
success: true,
data: updatedConfig,
message: "외부 호출 설정이 성공적으로 수정되었습니다.",
});
} catch (error) {
logger.error("외부 호출 설정 수정 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "외부 호출 설정 수정 실패",
errorCode: "EXTERNAL_CALL_CONFIG_UPDATE_ERROR",
});
}
});
/**
* ( )
* DELETE /api/external-call-configs/:id
*/
router.delete("/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
await externalCallConfigService.deleteConfig(id, userId);
return res.json({
success: true,
message: "외부 호출 설정이 성공적으로 삭제되었습니다.",
});
} catch (error) {
logger.error("외부 호출 설정 삭제 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "외부 호출 설정 삭제 실패",
errorCode: "EXTERNAL_CALL_CONFIG_DELETE_ERROR",
});
}
});
/**
*
* POST /api/external-call-configs/:id/test
*/
router.post("/:id/test", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
const testResult = await externalCallConfigService.testConfig(id);
return res.json({
success: testResult.success,
message: testResult.message,
data: testResult,
});
} catch (error) {
logger.error("외부 호출 설정 테스트 API 오류:", error);
return res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "외부 호출 설정 테스트 실패",
errorCode: "EXTERNAL_CALL_CONFIG_TEST_ERROR",
});
}
});
export default router;

View File

@ -0,0 +1,313 @@
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
// 외부 호출 설정 타입 정의
export interface ExternalCallConfig {
id?: number;
config_name: string;
call_type: string;
api_type?: string;
config_data: any;
description?: string;
company_code?: string;
is_active?: string;
created_by?: string;
updated_by?: string;
}
export interface ExternalCallConfigFilter {
company_code?: string;
call_type?: string;
api_type?: string;
is_active?: string;
search?: string;
}
export class ExternalCallConfigService {
/**
*
*/
async getConfigs(
filter: ExternalCallConfigFilter = {}
): Promise<ExternalCallConfig[]> {
try {
logger.info("=== 외부 호출 설정 목록 조회 시작 ===");
logger.info(`필터 조건:`, filter);
const where: any = {};
// 회사 코드 필터
if (filter.company_code) {
where.company_code = filter.company_code;
}
// 호출 타입 필터
if (filter.call_type) {
where.call_type = filter.call_type;
}
// API 타입 필터
if (filter.api_type) {
where.api_type = filter.api_type;
}
// 활성화 상태 필터
if (filter.is_active) {
where.is_active = filter.is_active;
}
// 검색어 필터 (설정 이름 또는 설명)
if (filter.search) {
where.OR = [
{ config_name: { contains: filter.search, mode: "insensitive" } },
{ description: { contains: filter.search, mode: "insensitive" } },
];
}
const configs = await prisma.external_call_configs.findMany({
where,
orderBy: [{ is_active: "desc" }, { created_date: "desc" }],
});
logger.info(`외부 호출 설정 조회 결과: ${configs.length}`);
return configs as ExternalCallConfig[];
} catch (error) {
logger.error("외부 호출 설정 목록 조회 실패:", error);
throw error;
}
}
/**
*
*/
async getConfigById(id: number): Promise<ExternalCallConfig | null> {
try {
logger.info(`=== 외부 호출 설정 조회: ID ${id} ===`);
const config = await prisma.external_call_configs.findUnique({
where: { id },
});
if (config) {
logger.info(`외부 호출 설정 조회 성공: ${config.config_name}`);
} else {
logger.warn(`외부 호출 설정을 찾을 수 없음: ID ${id}`);
}
return config as ExternalCallConfig | null;
} catch (error) {
logger.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
async createConfig(data: ExternalCallConfig): Promise<ExternalCallConfig> {
try {
logger.info("=== 외부 호출 설정 생성 시작 ===");
logger.info(`생성할 설정:`, {
config_name: data.config_name,
call_type: data.call_type,
api_type: data.api_type,
company_code: data.company_code || "*",
});
// 중복 이름 검사
const existingConfig = await prisma.external_call_configs.findFirst({
where: {
config_name: data.config_name,
company_code: data.company_code || "*",
is_active: "Y",
},
});
if (existingConfig) {
throw new Error(
`동일한 이름의 외부 호출 설정이 이미 존재합니다: ${data.config_name}`
);
}
const newConfig = await prisma.external_call_configs.create({
data: {
config_name: data.config_name,
call_type: data.call_type,
api_type: data.api_type,
config_data: data.config_data,
description: data.description,
company_code: data.company_code || "*",
is_active: data.is_active || "Y",
created_by: data.created_by,
updated_by: data.updated_by,
},
});
logger.info(
`외부 호출 설정 생성 완료: ${newConfig.config_name} (ID: ${newConfig.id})`
);
return newConfig as ExternalCallConfig;
} catch (error) {
logger.error("외부 호출 설정 생성 실패:", error);
throw error;
}
}
/**
*
*/
async updateConfig(
id: number,
data: Partial<ExternalCallConfig>
): Promise<ExternalCallConfig> {
try {
logger.info(`=== 외부 호출 설정 수정 시작: ID ${id} ===`);
// 기존 설정 존재 확인
const existingConfig = await this.getConfigById(id);
if (!existingConfig) {
throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`);
}
// 이름 중복 검사 (다른 설정과 중복되는지)
if (data.config_name && data.config_name !== existingConfig.config_name) {
const duplicateConfig = await prisma.external_call_configs.findFirst({
where: {
config_name: data.config_name,
company_code: data.company_code || existingConfig.company_code,
is_active: "Y",
id: { not: id },
},
});
if (duplicateConfig) {
throw new Error(
`동일한 이름의 외부 호출 설정이 이미 존재합니다: ${data.config_name}`
);
}
}
const updatedConfig = await prisma.external_call_configs.update({
where: { id },
data: {
...(data.config_name && { config_name: data.config_name }),
...(data.call_type && { call_type: data.call_type }),
...(data.api_type !== undefined && { api_type: data.api_type }),
...(data.config_data && { config_data: data.config_data }),
...(data.description !== undefined && {
description: data.description,
}),
...(data.company_code && { company_code: data.company_code }),
...(data.is_active && { is_active: data.is_active }),
...(data.updated_by && { updated_by: data.updated_by }),
updated_date: new Date(),
},
});
logger.info(
`외부 호출 설정 수정 완료: ${updatedConfig.config_name} (ID: ${id})`
);
return updatedConfig as ExternalCallConfig;
} catch (error) {
logger.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
* ( )
*/
async deleteConfig(id: number, deletedBy?: string): Promise<void> {
try {
logger.info(`=== 외부 호출 설정 삭제 시작: ID ${id} ===`);
// 기존 설정 존재 확인
const existingConfig = await this.getConfigById(id);
if (!existingConfig) {
throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`);
}
// 논리 삭제 (is_active = 'N')
await prisma.external_call_configs.update({
where: { id },
data: {
is_active: "N",
updated_by: deletedBy,
updated_date: new Date(),
},
});
logger.info(
`외부 호출 설정 삭제 완료: ${existingConfig.config_name} (ID: ${id})`
);
} catch (error) {
logger.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
async testConfig(id: number): Promise<{ success: boolean; message: string }> {
try {
logger.info(`=== 외부 호출 설정 테스트 시작: ID ${id} ===`);
const config = await this.getConfigById(id);
if (!config) {
throw new Error(`외부 호출 설정을 찾을 수 없습니다: ID ${id}`);
}
// TODO: ExternalCallService를 사용하여 실제 테스트 호출
// 현재는 기본적인 검증만 수행
const configData = config.config_data as any;
let isValid = true;
let validationMessage = "";
switch (config.api_type) {
case "discord":
if (!configData.webhookUrl) {
isValid = false;
validationMessage = "Discord 웹훅 URL이 필요합니다.";
}
break;
case "slack":
if (!configData.webhookUrl) {
isValid = false;
validationMessage = "Slack 웹훅 URL이 필요합니다.";
}
break;
case "kakao-talk":
if (!configData.accessToken) {
isValid = false;
validationMessage = "카카오톡 액세스 토큰이 필요합니다.";
}
break;
default:
if (config.call_type === "rest-api" && !configData.url) {
isValid = false;
validationMessage = "API URL이 필요합니다.";
}
}
if (!isValid) {
logger.warn(`외부 호출 설정 테스트 실패: ${validationMessage}`);
return { success: false, message: validationMessage };
}
logger.info(`외부 호출 설정 테스트 성공: ${config.config_name}`);
return { success: true, message: "설정이 유효합니다." };
} catch (error) {
logger.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error);
return {
success: false,
message: error instanceof Error ? error.message : "테스트 실패",
};
}
}
}
export default new ExternalCallConfigService();

View File

@ -124,4 +124,3 @@ export type SupportedExternalCallSettings =
| DiscordSettings
| GenericApiSettings
| EmailSettings;

View File

@ -0,0 +1,401 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
import {
ExternalCallConfigAPI,
ExternalCallConfig,
ExternalCallConfigFilter,
CALL_TYPE_OPTIONS,
API_TYPE_OPTIONS,
ACTIVE_STATUS_OPTIONS,
} from "@/lib/api/externalCallConfig";
import { ExternalCallConfigModal } from "@/components/admin/ExternalCallConfigModal";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function ExternalCallConfigsPage() {
const [configs, setConfigs] = useState<ExternalCallConfig[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<ExternalCallConfigFilter>({
is_active: "Y",
});
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ExternalCallConfig | null>(null);
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [configToDelete, setConfigToDelete] = useState<ExternalCallConfig | null>(null);
// 외부 호출 설정 목록 조회
const fetchConfigs = async () => {
try {
setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({
...filter,
search: searchQuery.trim() || undefined,
});
if (response.success) {
setConfigs(response.data || []);
} else {
toast.error(response.message || "외부 호출 설정 조회 실패");
}
} catch (error) {
console.error("외부 호출 설정 조회 오류:", error);
toast.error("외부 호출 설정 조회 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
// 초기 로드 및 필터/검색 변경 시 재조회
useEffect(() => {
fetchConfigs();
}, [filter]);
// 검색 실행
const handleSearch = () => {
fetchConfigs();
};
// 검색 입력 시 엔터키 처리
const handleSearchKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 새 설정 추가
const handleAddConfig = () => {
setEditingConfig(null);
setIsModalOpen(true);
};
// 설정 편집
const handleEditConfig = (config: ExternalCallConfig) => {
setEditingConfig(config);
setIsModalOpen(true);
};
// 설정 삭제 확인
const handleDeleteConfig = (config: ExternalCallConfig) => {
setConfigToDelete(config);
setDeleteDialogOpen(true);
};
// 설정 삭제 실행
const confirmDeleteConfig = async () => {
if (!configToDelete?.id) return;
try {
const response = await ExternalCallConfigAPI.deleteConfig(configToDelete.id);
if (response.success) {
toast.success("외부 호출 설정이 삭제되었습니다.");
fetchConfigs();
} else {
toast.error(response.message || "외부 호출 설정 삭제 실패");
}
} catch (error) {
console.error("외부 호출 설정 삭제 오류:", error);
toast.error("외부 호출 설정 삭제 중 오류가 발생했습니다.");
} finally {
setDeleteDialogOpen(false);
setConfigToDelete(null);
}
};
// 설정 테스트
const handleTestConfig = async (config: ExternalCallConfig) => {
if (!config.id) return;
try {
const response = await ExternalCallConfigAPI.testConfig(config.id);
if (response.success && response.data?.success) {
toast.success(`테스트 성공: ${response.data.message}`);
} else {
toast.error(`테스트 실패: ${response.data?.message || response.message}`);
}
} catch (error) {
console.error("외부 호출 설정 테스트 오류:", error);
toast.error("외부 호출 설정 테스트 중 오류가 발생했습니다.");
}
};
// 모달 저장 완료 시 목록 새로고침
const handleModalSave = () => {
setIsModalOpen(false);
setEditingConfig(null);
fetchConfigs();
};
// 호출 타입 라벨 가져오기
const getCallTypeLabel = (callType: string) => {
return CALL_TYPE_OPTIONS.find((option) => option.value === callType)?.label || callType;
};
// API 타입 라벨 가져오기
const getApiTypeLabel = (apiType?: string) => {
if (!apiType) return "";
return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType;
};
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold"> </h1>
<p className="text-muted-foreground mt-1">Discord, Slack, .</p>
</div>
<Button onClick={handleAddConfig} className="flex items-center gap-2">
<Plus size={16} />
</Button>
</div>
{/* 검색 및 필터 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter size={18} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 */}
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
<Button onClick={handleSearch} variant="outline">
<Search size={16} />
</Button>
</div>
{/* 필터 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium"> </label>
<Select
value={filter.call_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
call_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm font-medium">API </label>
<Select
value={filter.api_type || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
api_type: value === "all" ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Select
value={filter.is_active || "Y"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
is_active: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 설정 목록 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{loading ? (
// 로딩 상태
<div className="py-8 text-center">
<div className="text-muted-foreground"> ...</div>
</div>
) : configs.length === 0 ? (
// 빈 상태
<div className="py-12 text-center">
<div className="text-muted-foreground">
<Plus size={48} className="mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium"> .</p>
<p className="text-sm"> .</p>
</div>
</div>
) : (
// 설정 테이블 목록
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead>API </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow key={config.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{config.config_name}</TableCell>
<TableCell>
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
</TableCell>
<TableCell>
{config.api_type ? (
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
<div className="max-w-xs">
{config.description ? (
<span className="text-muted-foreground block truncate text-sm" title={config.description}>
{config.description}
</span>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
{config.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<div className="flex justify-center gap-1">
<Button size="sm" variant="outline" onClick={() => handleTestConfig(config)} title="테스트">
<TestTube size={14} />
</Button>
<Button size="sm" variant="outline" onClick={() => handleEditConfig(config)} title="편집">
<Edit size={14} />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteConfig(config)}
className="text-destructive hover:text-destructive"
title="삭제"
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 외부 호출 설정 모달 */}
<ExternalCallConfigModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleModalSave}
editingConfig={editingConfig}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{configToDelete?.config_name}" ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteConfig} className="bg-destructive hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,522 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import {
ExternalCallConfigAPI,
ExternalCallConfig,
CALL_TYPE_OPTIONS,
API_TYPE_OPTIONS,
ACTIVE_STATUS_OPTIONS,
} from "@/lib/api/externalCallConfig";
interface ExternalCallConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
editingConfig?: ExternalCallConfig | null;
}
export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig }: ExternalCallConfigModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<Partial<ExternalCallConfig>>({
config_name: "",
call_type: "rest-api",
api_type: "discord",
description: "",
is_active: "Y",
config_data: {},
});
// Discord 설정 상태
const [discordSettings, setDiscordSettings] = useState({
webhookUrl: "",
username: "",
avatarUrl: "",
});
// Slack 설정 상태
const [slackSettings, setSlackSettings] = useState({
webhookUrl: "",
channel: "",
username: "",
});
// 카카오톡 설정 상태
const [kakaoSettings, setKakaoSettings] = useState({
accessToken: "",
templateId: "",
});
// 일반 API 설정 상태
const [genericSettings, setGenericSettings] = useState({
url: "",
method: "POST",
headers: "{}",
timeout: "30000",
});
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen && editingConfig) {
setFormData({
config_name: editingConfig.config_name,
call_type: editingConfig.call_type,
api_type: editingConfig.api_type,
description: editingConfig.description || "",
is_active: editingConfig.is_active || "Y",
config_data: editingConfig.config_data,
});
// API 타입별 설정 데이터 로드
const configData = editingConfig.config_data as any;
if (editingConfig.api_type === "discord") {
setDiscordSettings({
webhookUrl: configData.webhookUrl || "",
username: configData.username || "",
avatarUrl: configData.avatarUrl || "",
});
} else if (editingConfig.api_type === "slack") {
setSlackSettings({
webhookUrl: configData.webhookUrl || "",
channel: configData.channel || "",
username: configData.username || "",
});
} else if (editingConfig.api_type === "kakao-talk") {
setKakaoSettings({
accessToken: configData.accessToken || "",
templateId: configData.templateId || "",
});
} else if (editingConfig.api_type === "generic") {
setGenericSettings({
url: configData.url || "",
method: configData.method || "POST",
headers: JSON.stringify(configData.headers || {}, null, 2),
timeout: String(configData.timeout || 30000),
});
}
} else if (isOpen && !editingConfig) {
// 새 설정 추가 시 초기화
setFormData({
config_name: "",
call_type: "rest-api",
api_type: "discord",
description: "",
is_active: "Y",
config_data: {},
});
setDiscordSettings({ webhookUrl: "", username: "", avatarUrl: "" });
setSlackSettings({ webhookUrl: "", channel: "", username: "" });
setKakaoSettings({ accessToken: "", templateId: "" });
setGenericSettings({ url: "", method: "POST", headers: "{}", timeout: "30000" });
}
}, [isOpen, editingConfig]);
// 호출 타입 변경 시 API 타입 초기화
const handleCallTypeChange = (callType: string) => {
setFormData((prev) => ({
...prev,
call_type: callType,
api_type: callType === "rest-api" ? "discord" : undefined,
}));
};
// config_data 생성
const generateConfigData = () => {
const { api_type } = formData;
switch (api_type) {
case "discord":
return {
webhookUrl: discordSettings.webhookUrl,
username: discordSettings.username || "ERP 시스템",
avatarUrl: discordSettings.avatarUrl || null,
};
case "slack":
return {
webhookUrl: slackSettings.webhookUrl,
channel: slackSettings.channel,
username: slackSettings.username || "ERP Bot",
};
case "kakao-talk":
return {
accessToken: kakaoSettings.accessToken,
templateId: kakaoSettings.templateId,
};
case "generic":
try {
return {
url: genericSettings.url,
method: genericSettings.method,
headers: JSON.parse(genericSettings.headers),
timeout: parseInt(genericSettings.timeout),
};
} catch (error) {
throw new Error("헤더 JSON 형식이 올바르지 않습니다.");
}
default:
return {};
}
};
// 폼 검증
const validateForm = () => {
if (!formData.config_name?.trim()) {
toast.error("설정 이름을 입력해주세요.");
return false;
}
if (!formData.call_type) {
toast.error("호출 타입을 선택해주세요.");
return false;
}
// REST API인 경우 API 타입별 검증
if (formData.call_type === "rest-api") {
switch (formData.api_type) {
case "discord":
if (!discordSettings.webhookUrl.trim()) {
toast.error("Discord 웹훅 URL을 입력해주세요.");
return false;
}
break;
case "slack":
if (!slackSettings.webhookUrl.trim()) {
toast.error("Slack 웹훅 URL을 입력해주세요.");
return false;
}
break;
case "kakao-talk":
if (!kakaoSettings.accessToken.trim()) {
toast.error("카카오톡 액세스 토큰을 입력해주세요.");
return false;
}
break;
case "generic":
if (!genericSettings.url.trim()) {
toast.error("API URL을 입력해주세요.");
return false;
}
try {
JSON.parse(genericSettings.headers);
} catch {
toast.error("헤더 JSON 형식이 올바르지 않습니다.");
return false;
}
break;
}
}
return true;
};
// 저장 처리
const handleSave = async () => {
if (!validateForm()) return;
try {
setLoading(true);
const configData = generateConfigData();
const saveData = {
...formData,
config_data: configData,
};
let response;
if (editingConfig?.id) {
response = await ExternalCallConfigAPI.updateConfig(editingConfig.id, saveData);
} else {
response = await ExternalCallConfigAPI.createConfig(saveData as any);
}
if (response.success) {
toast.success(editingConfig ? "외부 호출 설정이 수정되었습니다." : "외부 호출 설정이 생성되었습니다.");
onSave();
} else {
toast.error(response.message || "저장 실패");
}
} catch (error) {
console.error("외부 호출 설정 저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
<div>
<Label htmlFor="config_name"> *</Label>
<Input
id="config_name"
value={formData.config_name}
onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
placeholder="예: 개발팀 Discord"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="이 외부 호출 설정에 대한 설명을 입력하세요."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="call_type"> *</Label>
<Select value={formData.call_type} onValueChange={handleCallTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CALL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="is_active"></Label>
<Select
value={formData.is_active}
onValueChange={(value) => setFormData((prev) => ({ ...prev, is_active: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTIVE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* REST API 설정 */}
{formData.call_type === "rest-api" && (
<div className="space-y-4">
<div>
<Label htmlFor="api_type">API *</Label>
<Select
value={formData.api_type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, api_type: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{API_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Discord 설정 */}
{formData.api_type === "discord" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Discord </h4>
<div>
<Label htmlFor="discord_webhook"> URL *</Label>
<Input
id="discord_webhook"
value={discordSettings.webhookUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div>
<Label htmlFor="discord_username"></Label>
<Input
id="discord_username"
value={discordSettings.username}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP 시스템"
/>
</div>
<div>
<Label htmlFor="discord_avatar"> URL</Label>
<Input
id="discord_avatar"
value={discordSettings.avatarUrl}
onChange={(e) => setDiscordSettings((prev) => ({ ...prev, avatarUrl: e.target.value }))}
placeholder="https://example.com/avatar.png"
/>
</div>
</div>
)}
{/* Slack 설정 */}
{formData.api_type === "slack" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium">Slack </h4>
<div>
<Label htmlFor="slack_webhook"> URL *</Label>
<Input
id="slack_webhook"
value={slackSettings.webhookUrl}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
<div>
<Label htmlFor="slack_channel"></Label>
<Input
id="slack_channel"
value={slackSettings.channel}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, channel: e.target.value }))}
placeholder="#general"
/>
</div>
<div>
<Label htmlFor="slack_username"></Label>
<Input
id="slack_username"
value={slackSettings.username}
onChange={(e) => setSlackSettings((prev) => ({ ...prev, username: e.target.value }))}
placeholder="ERP Bot"
/>
</div>
</div>
)}
{/* 카카오톡 설정 */}
{formData.api_type === "kakao-talk" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> </h4>
<div>
<Label htmlFor="kakao_token"> *</Label>
<Input
id="kakao_token"
type="password"
value={kakaoSettings.accessToken}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, accessToken: e.target.value }))}
placeholder="카카오 API 액세스 토큰"
/>
</div>
<div>
<Label htmlFor="kakao_template">릿 ID</Label>
<Input
id="kakao_template"
value={kakaoSettings.templateId}
onChange={(e) => setKakaoSettings((prev) => ({ ...prev, templateId: e.target.value }))}
placeholder="template_001"
/>
</div>
</div>
)}
{/* 일반 API 설정 */}
{formData.api_type === "generic" && (
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-medium"> API </h4>
<div>
<Label htmlFor="generic_url">API URL *</Label>
<Input
id="generic_url"
value={genericSettings.url}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="generic_method">HTTP </Label>
<Select
value={genericSettings.method}
onValueChange={(value) => setGenericSettings((prev) => ({ ...prev, method: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="generic_timeout"> (ms)</Label>
<Input
id="generic_timeout"
type="number"
value={genericSettings.timeout}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, timeout: e.target.value }))}
placeholder="30000"
/>
</div>
</div>
<div>
<Label htmlFor="generic_headers"> (JSON)</Label>
<Textarea
id="generic_headers"
value={genericSettings.headers}
onChange={(e) => setGenericSettings((prev) => ({ ...prev, headers: e.target.value }))}
placeholder='{"Content-Type": "application/json"}'
rows={3}
/>
</div>
</div>
)}
</div>
)}
{/* 다른 호출 타입들 (이메일, FTP, 큐) */}
{formData.call_type !== "rest-api" && (
<div className="text-muted-foreground rounded-lg border p-4 text-center">
{formData.call_type} .
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -12,6 +12,7 @@ import {
SimpleKeySettings,
DataSaveSettings,
ExternalCallSettings,
SimpleExternalCallSettings,
ConnectionSetupModalProps,
} from "@/types/connectionTypes";
import { isConditionalConnection } from "@/utils/connectionUtils";
@ -20,7 +21,7 @@ import { ConditionalSettings } from "./condition/ConditionalSettings";
import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
import { ExternalCallSettings as ExternalCallSettingsComponent } from "./connection/ExternalCallSettings";
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
import toast from "react-hot-toast";
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
@ -47,12 +48,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
actions: [],
});
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
callType: "rest-api",
apiUrl: "",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
const [externalCallSettings, setExternalCallSettings] = useState<SimpleExternalCallSettings>({
message: "",
});
// 테이블 및 컬럼 선택을 위한 상태들
@ -147,21 +144,21 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
}
}
} else if (connectionType === "external-call") {
// 외부 호출 설정은 plan에서 로드
const settingsRecord = settings as Record<string, unknown>;
let externalCallData: Record<string, unknown> = {};
if (settingsRecord.plan && typeof settingsRecord.plan === "object" && settingsRecord.plan !== null) {
const planRecord = settingsRecord.plan as Record<string, unknown>;
if (planRecord.externalCall && typeof planRecord.externalCall === "object") {
externalCallData = planRecord.externalCall as Record<string, unknown>;
}
}
setExternalCallSettings({
callType: (settings.callType as "rest-api" | "email" | "ftp" | "queue") || "rest-api",
apiUrl: (settings.apiUrl as string) || "",
httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST",
headers: (settings.headers as string) || "{}",
bodyTemplate: (settings.bodyTemplate as string) || "{}",
// 새로운 필드들도 로드
apiType: (settings.apiType as "slack" | "kakao-talk" | "discord" | "generic") || "generic",
slackWebhookUrl: (settings.slackWebhookUrl as string) || "",
slackChannel: (settings.slackChannel as string) || "",
slackMessage: (settings.slackMessage as string) || "",
kakaoAccessToken: (settings.kakaoAccessToken as string) || "",
kakaoMessage: (settings.kakaoMessage as string) || "",
discordWebhookUrl: (settings.discordWebhookUrl as string) || "",
discordMessage: (settings.discordMessage as string) || "",
configId: (externalCallData.configId as number) || undefined,
configName: (externalCallData.configName as string) || undefined,
message: (externalCallData.message as string) || "",
});
}
},
@ -338,6 +335,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
// 연결 종류별 설정을 준비
let settings = {};
let plan = {}; // plan 변수 선언
switch (config.connectionType) {
case "simple-key":
@ -347,7 +345,15 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
// 외부 호출은 plan에 저장
plan = {
externalCall: {
configId: externalCallSettings.configId,
configName: externalCallSettings.configName,
message: externalCallSettings.message,
},
};
settings = {}; // 외부 호출은 settings에 저장하지 않음
break;
}
@ -421,6 +427,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
settings: {
...settings,
...conditionalSettings, // 조건부 연결 설정 추가
...plan, // 외부 호출 plan 추가
},
};
@ -521,33 +528,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
case "external-call":
// 외부 호출: 호출 타입과 필수 설정이 있어야 함
const hasCallType = !!externalCallSettings.callType;
if (!hasCallType) return true;
switch (externalCallSettings.callType) {
case "rest-api":
// REST API의 경우 apiType에 따라 다른 검증
switch (externalCallSettings.apiType) {
case "slack":
return !externalCallSettings.slackWebhookUrl?.trim() || !externalCallSettings.slackMessage?.trim();
case "kakao-talk":
return !externalCallSettings.kakaoAccessToken?.trim() || !externalCallSettings.kakaoMessage?.trim();
case "discord":
return !externalCallSettings.discordWebhookUrl?.trim() || !externalCallSettings.discordMessage?.trim();
case "generic":
default:
return !externalCallSettings.apiUrl?.trim();
}
case "email":
return !externalCallSettings.apiUrl?.trim(); // 이메일 서버 URL 필요
case "ftp":
return !externalCallSettings.apiUrl?.trim(); // FTP 서버 URL 필요
case "queue":
return !externalCallSettings.apiUrl?.trim(); // 큐 서버 URL 필요
default:
return true;
}
// 외부 호출: 설정 ID와 메시지가 있어야 함
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
default:
return false;

View File

@ -0,0 +1,275 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ExternalCallConfigAPI, ExternalCallConfig } from "@/lib/api/externalCallConfig";
import { ExternalCallAPI } from "@/lib/api/externalCall";
import { toast } from "sonner";
import { Globe, TestTube, RefreshCw } from "lucide-react";
// 단순화된 외부 호출 설정 타입
export interface SimpleExternalCallSettings {
configId?: number; // 선택된 외부 호출 설정 ID
configName?: string; // 설정 이름 (표시용)
message: string; // 메시지 템플릿
}
interface SimpleExternalCallSettingsProps {
settings: SimpleExternalCallSettings;
onSettingsChange: (settings: SimpleExternalCallSettings) => void;
}
export function SimpleExternalCallSettings({ settings, onSettingsChange }: SimpleExternalCallSettingsProps) {
const [availableConfigs, setAvailableConfigs] = useState<ExternalCallConfig[]>([]);
const [loading, setLoading] = useState(true);
const [testLoading, setTestLoading] = useState(false);
// 사용 가능한 외부 호출 설정 목록 조회
const fetchAvailableConfigs = async () => {
try {
setLoading(true);
const response = await ExternalCallConfigAPI.getConfigs({
is_active: "Y", // 활성화된 설정만 조회
});
if (response.success) {
setAvailableConfigs(response.data || []);
} else {
toast.error("외부 호출 설정 목록 조회 실패");
}
} catch (error) {
console.error("외부 호출 설정 목록 조회 오류:", error);
toast.error("외부 호출 설정을 불러올 수 없습니다.");
} finally {
setLoading(false);
}
};
// 컴포넌트 마운트 시 설정 목록 조회
useEffect(() => {
fetchAvailableConfigs();
}, []);
// 외부 호출 설정 선택
const handleConfigChange = (configId: string) => {
if (configId === "none") {
onSettingsChange({
...settings,
configId: undefined,
configName: undefined,
});
return;
}
const selectedConfig = availableConfigs.find((config) => config.id?.toString() === configId);
if (selectedConfig) {
onSettingsChange({
...settings,
configId: selectedConfig.id,
configName: selectedConfig.config_name,
});
}
};
// 메시지 변경
const handleMessageChange = (message: string) => {
onSettingsChange({
...settings,
message,
});
};
// 테스트 실행
const handleTest = async () => {
if (!settings.configId) {
toast.error("외부 호출 설정을 선택해주세요.");
return;
}
if (!settings.message.trim()) {
toast.error("메시지를 입력해주세요.");
return;
}
try {
setTestLoading(true);
// 선택된 설정 정보 가져오기
const configResponse = await ExternalCallConfigAPI.getConfigById(settings.configId);
if (!configResponse.success || !configResponse.data) {
toast.error("외부 호출 설정을 찾을 수 없습니다.");
return;
}
const config = configResponse.data;
const configData = config.config_data as any;
// 백엔드 API용 설정 변환
const backendSettings: Record<string, unknown> = {
callType: config.call_type,
apiType: config.api_type,
message: settings.message,
timeout: 10000,
};
// API 타입별 설정 추가
switch (config.api_type) {
case "discord":
backendSettings.webhookUrl = configData.webhookUrl;
backendSettings.username = configData.username;
if (configData.avatarUrl) {
backendSettings.avatarUrl = configData.avatarUrl;
}
break;
case "slack":
backendSettings.webhookUrl = configData.webhookUrl;
if (configData.channel) {
backendSettings.channel = configData.channel;
}
if (configData.username) {
backendSettings.username = configData.username;
}
break;
case "kakao-talk":
backendSettings.accessToken = configData.accessToken;
if (configData.templateId) {
backendSettings.templateId = configData.templateId;
}
break;
case "generic":
backendSettings.url = configData.url;
backendSettings.method = configData.method || "POST";
backendSettings.headers = configData.headers || {};
if (configData.timeout) {
backendSettings.timeout = configData.timeout;
}
break;
}
// 테스트 실행 (10초 타임아웃)
const testPromise = ExternalCallAPI.testExternalCall({
settings: backendSettings,
templateData: { recordCount: 5, timestamp: new Date().toISOString() },
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("테스트가 10초 내에 완료되지 않았습니다.")), 10000),
);
const result = await Promise.race([testPromise, timeoutPromise]);
if ((result as any).success) {
toast.success(`테스트 성공: ${config.config_name}`);
} else {
toast.error(`테스트 실패: ${(result as any).message || "알 수 없는 오류"}`);
}
} catch (error) {
console.error("외부 호출 테스트 오류:", error);
if (error instanceof Error && error.message.includes("10초")) {
toast.error("테스트 타임아웃: 10초 내에 응답이 없습니다.");
} else {
toast.error("테스트 실행 중 오류가 발생했습니다.");
}
} finally {
setTestLoading(false);
}
};
// 선택된 설정 정보
const selectedConfig = availableConfigs.find((config) => config.id === settings.configId);
return (
<div className="space-y-4">
<div className="mb-4 flex items-center gap-2">
<Globe size={18} />
<h3 className="text-lg font-medium"> </h3>
</div>
{/* 외부 호출 설정 선택 */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label htmlFor="external-call-config"> *</Label>
<Button variant="ghost" size="sm" onClick={fetchAvailableConfigs} disabled={loading} className="h-6 px-2">
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</Button>
</div>
<Select value={settings.configId?.toString() || "none"} onValueChange={handleConfigChange} disabled={loading}>
<SelectTrigger>
<SelectValue placeholder={loading ? "로딩 중..." : "외부 호출 설정을 선택하세요"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{availableConfigs.map((config) => (
<SelectItem key={config.id} value={config.id!.toString()}>
<div className="flex flex-col">
<span className="font-medium">{config.config_name}</span>
<span className="text-muted-foreground text-xs">
{config.call_type === "rest-api" && config.api_type
? `${config.call_type} - ${config.api_type}`
: config.call_type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 선택된 설정 정보 표시 */}
{selectedConfig && (
<div className="bg-muted/50 mt-2 rounded-lg p-3 text-sm">
<div className="font-medium">{selectedConfig.config_name}</div>
{selectedConfig.description && (
<div className="text-muted-foreground mt-1">{selectedConfig.description}</div>
)}
</div>
)}
</div>
{/* 메시지 템플릿 */}
<div>
<Label htmlFor="external-call-message"> 릿 *</Label>
<Textarea
id="external-call-message"
value={settings.message}
onChange={(e) => handleMessageChange(e.target.value)}
placeholder="데이터 처리가 완료되었습니다! 총 {{recordCount}}건이 처리되었습니다."
rows={3}
className="text-sm"
/>
<div className="text-muted-foreground mt-1 text-xs">
릿 : {{ recordCount }}, {{ timestamp }}, {{ tableName }} .
</div>
</div>
{/* 테스트 버튼 */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!settings.configId || !settings.message.trim() || testLoading}
className="flex items-center gap-2"
>
<TestTube size={14} />
{testLoading ? "테스트 중..." : "테스트"}
</Button>
</div>
{/* 설정이 없는 경우 안내 */}
{!loading && availableConfigs.length === 0 && (
<div className="text-muted-foreground rounded-lg border p-4 text-center">
<Globe size={32} className="mx-auto mb-2 opacity-50" />
<p className="font-medium"> .</p>
<p className="text-sm"> .</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,150 @@
import { apiClient, ApiResponse } from "./client";
// 외부 호출 설정 타입 정의
export interface ExternalCallConfig {
id?: number;
config_name: string;
call_type: string;
api_type?: string;
config_data: Record<string, unknown>;
description?: string;
company_code?: string;
is_active?: string;
created_date?: string;
created_by?: string;
updated_date?: string;
updated_by?: string;
}
export interface ExternalCallConfigFilter {
company_code?: string;
call_type?: string;
api_type?: string;
is_active?: string;
search?: string;
}
export interface ExternalCallConfigTestResult {
success: boolean;
message: string;
}
/**
* API
*/
export class ExternalCallConfigAPI {
private static readonly BASE_URL = "/external-call-configs";
/**
*
*/
static async getConfigs(filter?: ExternalCallConfigFilter): Promise<ApiResponse<ExternalCallConfig[]>> {
try {
const params = new URLSearchParams();
if (filter?.company_code) params.append("company_code", filter.company_code);
if (filter?.call_type) params.append("call_type", filter.call_type);
if (filter?.api_type) params.append("api_type", filter.api_type);
if (filter?.is_active) params.append("is_active", filter.is_active);
if (filter?.search) params.append("search", filter.search);
const url = params.toString() ? `${this.BASE_URL}?${params.toString()}` : this.BASE_URL;
const response = await apiClient.get(url);
return response.data;
} catch (error) {
console.error("외부 호출 설정 목록 조회 실패:", error);
throw error;
}
}
/**
*
*/
static async getConfigById(id: number): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.get(`${this.BASE_URL}/${id}`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async createConfig(
config: Omit<ExternalCallConfig, "id" | "created_date" | "updated_date">,
): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.post(this.BASE_URL, config);
return response.data;
} catch (error) {
console.error("외부 호출 설정 생성 실패:", error);
throw error;
}
}
/**
*
*/
static async updateConfig(id: number, config: Partial<ExternalCallConfig>): Promise<ApiResponse<ExternalCallConfig>> {
try {
const response = await apiClient.put(`${this.BASE_URL}/${id}`, config);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async deleteConfig(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`${this.BASE_URL}/${id}`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 삭제 실패 (ID: ${id}):`, error);
throw error;
}
}
/**
*
*/
static async testConfig(id: number): Promise<ApiResponse<ExternalCallConfigTestResult>> {
try {
const response = await apiClient.post(`${this.BASE_URL}/${id}/test`);
return response.data;
} catch (error) {
console.error(`외부 호출 설정 테스트 실패 (ID: ${id}):`, error);
throw error;
}
}
}
// 호출 타입 옵션
export const CALL_TYPE_OPTIONS = [
{ value: "rest-api", label: "REST API" },
{ value: "email", label: "이메일" },
{ value: "ftp", label: "FTP" },
{ value: "queue", label: "큐" },
];
// API 타입 옵션 (REST API 전용)
export const API_TYPE_OPTIONS = [
{ value: "discord", label: "Discord" },
{ value: "slack", label: "Slack" },
{ value: "kakao-talk", label: "카카오톡" },
{ value: "mattermost", label: "Mattermost" },
{ value: "generic", label: "기타 (일반 API)" },
];
// 활성화 상태 옵션
export const ACTIVE_STATUS_OPTIONS = [
{ value: "Y", label: "활성" },
{ value: "N", label: "비활성" },
];

View File

@ -93,6 +93,13 @@ export interface ExternalCallSettings {
discordUsername?: string;
}
// 단순화된 외부 호출 설정 (새로운 버전)
export interface SimpleExternalCallSettings {
configId?: number; // 선택된 외부 호출 설정 ID
configName?: string; // 설정 이름 (표시용)
message: string; // 메시지 템플릿
}
// ConnectionSetupModal Props 타입
export interface ConnectionSetupModalProps {
isOpen: boolean;