Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
1823415a5b
|
|
@ -282,7 +282,7 @@ app.listen(PORT, HOST, async () => {
|
||||||
|
|
||||||
// 배치 스케줄러 초기화
|
// 배치 스케줄러 초기화
|
||||||
try {
|
try {
|
||||||
await BatchSchedulerService.initialize();
|
await BatchSchedulerService.initializeScheduler();
|
||||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
|
import https from "https";
|
||||||
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { DashboardService } from "../services/DashboardService";
|
import { DashboardService } from "../services/DashboardService";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,6 +10,7 @@ import {
|
||||||
DashboardListQuery,
|
DashboardListQuery,
|
||||||
} from "../types/dashboard";
|
} from "../types/dashboard";
|
||||||
import { PostgreSQLService } from "../database/PostgreSQLService";
|
import { PostgreSQLService } from "../database/PostgreSQLService";
|
||||||
|
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 컨트롤러
|
* 대시보드 컨트롤러
|
||||||
|
|
@ -415,7 +419,7 @@ export class DashboardController {
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
category: req.query.category as string,
|
category: req.query.category as string,
|
||||||
createdBy: userId, // 본인이 만든 대시보드만
|
// createdBy 제거 - 회사 대시보드 전체 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(
|
const result = await DashboardService.getDashboards(
|
||||||
|
|
@ -590,7 +594,14 @@ export class DashboardController {
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { url, method = "GET", headers = {}, queryParams = {} } = req.body;
|
const {
|
||||||
|
url,
|
||||||
|
method = "GET",
|
||||||
|
headers = {},
|
||||||
|
queryParams = {},
|
||||||
|
body,
|
||||||
|
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -608,85 +619,131 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 외부 API 호출 (타임아웃 30초)
|
// Axios 요청 설정
|
||||||
// @ts-ignore - node-fetch dynamic import
|
const requestConfig: AxiosRequestConfig = {
|
||||||
const fetch = (await import("node-fetch")).default;
|
url: urlObj.toString(),
|
||||||
|
method: method.toUpperCase(),
|
||||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
headers: {
|
||||||
const controller = new (global as any).AbortController();
|
"Content-Type": "application/json",
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
Accept: "application/json",
|
||||||
|
...headers,
|
||||||
let response;
|
},
|
||||||
try {
|
timeout: 60000, // 60초 타임아웃
|
||||||
response = await fetch(urlObj.toString(), {
|
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||||
method: method.toUpperCase(),
|
};
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||||
...headers,
|
if (externalConnectionId) {
|
||||||
},
|
try {
|
||||||
signal: controller.signal,
|
// 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
|
||||||
});
|
let companyCode = req.user?.companyCode;
|
||||||
clearTimeout(timeoutId);
|
|
||||||
} catch (err: any) {
|
if (!companyCode) {
|
||||||
clearTimeout(timeoutId);
|
companyCode = "*";
|
||||||
if (err.name === 'AbortError') {
|
}
|
||||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
|
||||||
|
// 커넥션 로드
|
||||||
|
const connectionResult =
|
||||||
|
await ExternalRestApiConnectionService.getConnectionById(
|
||||||
|
Number(externalConnectionId),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionResult.success && connectionResult.data) {
|
||||||
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 인증 헤더 생성 (DB 토큰 등)
|
||||||
|
const authHeaders =
|
||||||
|
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||||
|
connection.auth_type,
|
||||||
|
connection.auth_config,
|
||||||
|
connection.company_code
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 헤더에 인증 헤더 병합
|
||||||
|
requestConfig.headers = {
|
||||||
|
...requestConfig.headers,
|
||||||
|
...authHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Key가 Query Param인 경우 처리
|
||||||
|
if (
|
||||||
|
connection.auth_type === "api-key" &&
|
||||||
|
connection.auth_config?.keyLocation === "query" &&
|
||||||
|
connection.auth_config?.keyName &&
|
||||||
|
connection.auth_config?.keyValue
|
||||||
|
) {
|
||||||
|
const currentUrl = new URL(requestConfig.url!);
|
||||||
|
currentUrl.searchParams.append(
|
||||||
|
connection.auth_config.keyName,
|
||||||
|
connection.auth_config.keyValue
|
||||||
|
);
|
||||||
|
requestConfig.url = currentUrl.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (connError) {
|
||||||
|
logger.error(
|
||||||
|
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
|
||||||
|
connError
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
// Body 처리
|
||||||
|
if (body) {
|
||||||
|
requestConfig.data = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||||
|
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||||
|
const bypassDomains = ["thiratis.com"];
|
||||||
|
const hostname = urlObj.hostname;
|
||||||
|
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||||
|
hostname.includes(domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldBypassTls) {
|
||||||
|
requestConfig.httpsAgent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios(requestConfig);
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`외부 API 오류: ${response.status} ${response.statusText}`
|
`외부 API 오류: ${response.status} ${response.statusText}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content-Type에 따라 응답 파싱
|
let data = response.data;
|
||||||
const contentType = response.headers.get("content-type");
|
const contentType = response.headers["content-type"];
|
||||||
let data: any;
|
|
||||||
|
|
||||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
// 텍스트 응답인 경우 포맷팅
|
||||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
if (typeof data === "string") {
|
||||||
urlObj.hostname.includes('data.go.kr');
|
data = { text: data, contentType };
|
||||||
|
|
||||||
if (isKoreanApi) {
|
|
||||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
const decoder = new TextDecoder('euc-kr');
|
|
||||||
const text = decoder.decode(buffer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
data = { text, contentType };
|
|
||||||
}
|
|
||||||
} else if (contentType && contentType.includes("application/json")) {
|
|
||||||
data = await response.json();
|
|
||||||
} else if (contentType && contentType.includes("text/")) {
|
|
||||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
|
||||||
const text = await response.text();
|
|
||||||
data = { text, contentType };
|
|
||||||
} else {
|
|
||||||
// 기타 응답 (JSON으로 시도)
|
|
||||||
try {
|
|
||||||
data = await response.json();
|
|
||||||
} catch {
|
|
||||||
const text = await response.text();
|
|
||||||
data = { text, contentType };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
const status = error.response?.status || 500;
|
||||||
|
const message = error.response?.statusText || error.message;
|
||||||
|
|
||||||
|
logger.error("외부 API 호출 오류:", {
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "외부 API 호출 중 오류가 발생했습니다.",
|
message: "외부 API 호출 중 오류가 발생했습니다.",
|
||||||
error:
|
error:
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? (error as Error).message
|
? message
|
||||||
: "외부 API 호출 오류",
|
: "외부 API 호출 오류",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -594,7 +594,7 @@ export class BatchManagementController {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// 스케줄러에 자동 등록 ✅
|
// 스케줄러에 자동 등록 ✅
|
||||||
try {
|
try {
|
||||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
await BatchSchedulerService.scheduleBatch(result.data);
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,19 @@ export const getLayouts = async (
|
||||||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||||
WHERE l.company_code = $1
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [companyCode];
|
const params: any[] = [];
|
||||||
let paramIndex = 2;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 최고 관리자는 모든 레이아웃 조회 가능
|
||||||
|
if (companyCode && companyCode !== '*') {
|
||||||
|
query += ` WHERE l.company_code = $${paramIndex}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
} else {
|
||||||
|
query += ` WHERE 1=1`;
|
||||||
|
}
|
||||||
|
|
||||||
if (externalDbConnectionId) {
|
if (externalDbConnectionId) {
|
||||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||||
|
|
@ -75,14 +83,27 @@ export const getLayoutById = async (
|
||||||
const companyCode = req.user?.companyCode;
|
const companyCode = req.user?.companyCode;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// 레이아웃 기본 정보
|
// 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
|
||||||
const layoutQuery = `
|
let layoutQuery: string;
|
||||||
SELECT l.*
|
let layoutParams: any[];
|
||||||
FROM digital_twin_layout l
|
|
||||||
WHERE l.id = $1 AND l.company_code = $2
|
|
||||||
`;
|
|
||||||
|
|
||||||
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
if (companyCode && companyCode !== '*') {
|
||||||
|
layoutQuery = `
|
||||||
|
SELECT l.*
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
WHERE l.id = $1 AND l.company_code = $2
|
||||||
|
`;
|
||||||
|
layoutParams = [id, companyCode];
|
||||||
|
} else {
|
||||||
|
layoutQuery = `
|
||||||
|
SELECT l.*
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
WHERE l.id = $1
|
||||||
|
`;
|
||||||
|
layoutParams = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutResult = await pool.query(layoutQuery, layoutParams);
|
||||||
|
|
||||||
if (layoutResult.rowCount === 0) {
|
if (layoutResult.rowCount === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
|
||||||
|
|
@ -178,21 +178,24 @@ export class DashboardService {
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 회사 코드 필터링 (최우선)
|
// 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||||
if (companyCode) {
|
if (companyCode) {
|
||||||
whereConditions.push(`d.company_code = $${paramIndex}`);
|
if (companyCode === '*') {
|
||||||
params.push(companyCode);
|
// 최고 관리자는 모든 대시보드 조회 가능
|
||||||
paramIndex++;
|
} else {
|
||||||
}
|
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
// 권한 필터링
|
paramIndex++;
|
||||||
if (userId) {
|
}
|
||||||
|
} else if (userId) {
|
||||||
|
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
`(d.created_by = $${paramIndex} OR d.is_public = true)`
|
||||||
);
|
);
|
||||||
params.push(userId);
|
params.push(userId);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
} else {
|
||||||
|
// 비로그인 사용자는 공개 대시보드만
|
||||||
whereConditions.push("d.is_public = true");
|
whereConditions.push("d.is_public = true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +231,7 @@ export class DashboardService {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
// 대시보드 목록 조회 (users 테이블 조인 제거)
|
// 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
|
||||||
const dashboardQuery = `
|
const dashboardQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
d.id,
|
d.id,
|
||||||
|
|
@ -242,13 +245,16 @@ export class DashboardService {
|
||||||
d.tags,
|
d.tags,
|
||||||
d.category,
|
d.category,
|
||||||
d.view_count,
|
d.view_count,
|
||||||
|
d.company_code,
|
||||||
|
u.user_name as created_by_name,
|
||||||
COUNT(de.id) as elements_count
|
COUNT(de.id) as elements_count
|
||||||
FROM dashboards d
|
FROM dashboards d
|
||||||
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
||||||
|
LEFT JOIN user_info u ON d.created_by = u.user_id
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
||||||
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
||||||
d.view_count
|
d.view_count, d.company_code, u.user_name
|
||||||
ORDER BY d.updated_at DESC
|
ORDER BY d.updated_at DESC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
@ -277,12 +283,14 @@ export class DashboardService {
|
||||||
thumbnailUrl: row.thumbnail_url,
|
thumbnailUrl: row.thumbnail_url,
|
||||||
isPublic: row.is_public,
|
isPublic: row.is_public,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
|
createdByName: row.created_by_name || row.created_by,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
tags: JSON.parse(row.tags || "[]"),
|
tags: JSON.parse(row.tags || "[]"),
|
||||||
category: row.category,
|
category: row.category,
|
||||||
viewCount: parseInt(row.view_count || "0"),
|
viewCount: parseInt(row.view_count || "0"),
|
||||||
elementsCount: parseInt(row.elements_count || "0"),
|
elementsCount: parseInt(row.elements_count || "0"),
|
||||||
|
companyCode: row.company_code,
|
||||||
})),
|
})),
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,14 @@ export class BatchSchedulerService {
|
||||||
try {
|
try {
|
||||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||||
|
|
||||||
|
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
|
||||||
|
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||||
|
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||||
|
if (fullConfig.success && fullConfig.data) {
|
||||||
|
config = fullConfig.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
const executionLogResponse =
|
const executionLogResponse =
|
||||||
await BatchExecutionLogService.createExecutionLog({
|
await BatchExecutionLogService.createExecutionLog({
|
||||||
|
|
|
||||||
|
|
@ -474,6 +474,105 @@ export class ExternalRestApiConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 헤더 생성
|
||||||
|
*/
|
||||||
|
static async getAuthHeaders(
|
||||||
|
authType: AuthType,
|
||||||
|
authConfig: any,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (authType === "db-token") {
|
||||||
|
const cfg = authConfig || {};
|
||||||
|
const {
|
||||||
|
dbTableName,
|
||||||
|
dbValueColumn,
|
||||||
|
dbWhereColumn,
|
||||||
|
dbWhereValue,
|
||||||
|
dbHeaderName,
|
||||||
|
dbHeaderTemplate,
|
||||||
|
} = cfg;
|
||||||
|
|
||||||
|
if (!dbTableName || !dbValueColumn) {
|
||||||
|
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWhereColumn = !!dbWhereColumn;
|
||||||
|
const hasWhereValue =
|
||||||
|
dbWhereValue !== undefined &&
|
||||||
|
dbWhereValue !== null &&
|
||||||
|
dbWhereValue !== "";
|
||||||
|
|
||||||
|
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||||
|
if (hasWhereColumn !== hasWhereValue) {
|
||||||
|
throw new Error(
|
||||||
|
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 식별자 검증 (간단한 화이트리스트)
|
||||||
|
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
if (
|
||||||
|
!identifierRegex.test(dbTableName) ||
|
||||||
|
!identifierRegex.test(dbValueColumn) ||
|
||||||
|
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT ${dbValueColumn} AS token_value
|
||||||
|
FROM ${dbTableName}
|
||||||
|
WHERE company_code = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
|
||||||
|
if (hasWhereColumn && hasWhereValue) {
|
||||||
|
sql += ` AND ${dbWhereColumn} = $2`;
|
||||||
|
params.push(dbWhereValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||||
|
|
||||||
|
if (tokenResult.rowCount === 0) {
|
||||||
|
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||||
|
const headerName = dbHeaderName || "Authorization";
|
||||||
|
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||||
|
|
||||||
|
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||||
|
} else if (authType === "bearer" && authConfig?.token) {
|
||||||
|
headers["Authorization"] = `Bearer ${authConfig.token}`;
|
||||||
|
} else if (authType === "basic" && authConfig) {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${authConfig.username}:${authConfig.password}`
|
||||||
|
).toString("base64");
|
||||||
|
headers["Authorization"] = `Basic ${credentials}`;
|
||||||
|
} else if (authType === "api-key" && authConfig) {
|
||||||
|
if (authConfig.keyLocation === "header") {
|
||||||
|
headers[authConfig.keyName] = authConfig.keyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||||
*/
|
*/
|
||||||
|
|
@ -485,99 +584,15 @@ export class ExternalRestApiConnectionService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 헤더 구성
|
// 헤더 구성
|
||||||
const headers = { ...testRequest.headers };
|
let headers = { ...testRequest.headers };
|
||||||
|
|
||||||
// 인증 헤더 추가
|
// 인증 헤더 생성 및 병합
|
||||||
if (testRequest.auth_type === "db-token") {
|
const authHeaders = await this.getAuthHeaders(
|
||||||
const cfg = testRequest.auth_config || {};
|
testRequest.auth_type,
|
||||||
const {
|
testRequest.auth_config,
|
||||||
dbTableName,
|
userCompanyCode
|
||||||
dbValueColumn,
|
);
|
||||||
dbWhereColumn,
|
headers = { ...headers, ...authHeaders };
|
||||||
dbWhereValue,
|
|
||||||
dbHeaderName,
|
|
||||||
dbHeaderTemplate,
|
|
||||||
} = cfg;
|
|
||||||
|
|
||||||
if (!dbTableName || !dbValueColumn) {
|
|
||||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userCompanyCode) {
|
|
||||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasWhereColumn = !!dbWhereColumn;
|
|
||||||
const hasWhereValue =
|
|
||||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
|
||||||
|
|
||||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
|
||||||
if (hasWhereColumn !== hasWhereValue) {
|
|
||||||
throw new Error(
|
|
||||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 식별자 검증 (간단한 화이트리스트)
|
|
||||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
||||||
if (
|
|
||||||
!identifierRegex.test(dbTableName) ||
|
|
||||||
!identifierRegex.test(dbValueColumn) ||
|
|
||||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sql = `
|
|
||||||
SELECT ${dbValueColumn} AS token_value
|
|
||||||
FROM ${dbTableName}
|
|
||||||
WHERE company_code = $1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params: any[] = [userCompanyCode];
|
|
||||||
|
|
||||||
if (hasWhereColumn && hasWhereValue) {
|
|
||||||
sql += ` AND ${dbWhereColumn} = $2`;
|
|
||||||
params.push(dbWhereValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += `
|
|
||||||
ORDER BY updated_date DESC
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
|
||||||
|
|
||||||
if (tokenResult.rowCount === 0) {
|
|
||||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
|
||||||
const headerName = dbHeaderName || "Authorization";
|
|
||||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
|
||||||
|
|
||||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
|
||||||
} else if (
|
|
||||||
testRequest.auth_type === "bearer" &&
|
|
||||||
testRequest.auth_config?.token
|
|
||||||
) {
|
|
||||||
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
|
|
||||||
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
|
|
||||||
const credentials = Buffer.from(
|
|
||||||
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
|
|
||||||
).toString("base64");
|
|
||||||
headers["Authorization"] = `Basic ${credentials}`;
|
|
||||||
} else if (
|
|
||||||
testRequest.auth_type === "api-key" &&
|
|
||||||
testRequest.auth_config
|
|
||||||
) {
|
|
||||||
if (testRequest.auth_config.keyLocation === "header") {
|
|
||||||
headers[testRequest.auth_config.keyName] =
|
|
||||||
testRequest.auth_config.keyValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL 구성
|
// URL 구성
|
||||||
let url = testRequest.base_url;
|
let url = testRequest.base_url;
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export default function DashboardListClient() {
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
|
@ -209,6 +210,9 @@ export default function DashboardListClient() {
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -277,6 +281,7 @@ export default function DashboardListClient() {
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
|
@ -296,6 +301,9 @@ export default function DashboardListClient() {
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
{dashboard.description || "-"}
|
{dashboard.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
{formatDate(dashboard.createdAt)}
|
{formatDate(dashboard.createdAt)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -363,6 +371,10 @@ export default function DashboardListClient() {
|
||||||
<span className="text-muted-foreground">설명</span>
|
<span className="text-muted-foreground">설명</span>
|
||||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성자</span>
|
||||||
|
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">생성일</span>
|
<span className="text-muted-foreground">생성일</span>
|
||||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
|
@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
|
||||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
||||||
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
||||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||||
|
|
@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
loadApiConnections();
|
loadApiConnections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataSource.externalConnectionId) {
|
||||||
|
setSelectedConnectionId(dataSource.externalConnectionId);
|
||||||
|
}
|
||||||
|
}, [dataSource.externalConnectionId]);
|
||||||
|
|
||||||
// 외부 커넥션 선택 핸들러
|
// 외부 커넥션 선택 핸들러
|
||||||
const handleConnectionSelect = async (connectionId: string) => {
|
const handleConnectionSelect = async (connectionId: string) => {
|
||||||
setSelectedConnectionId(connectionId);
|
setSelectedConnectionId(connectionId);
|
||||||
|
|
@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
const updates: Partial<ChartDataSource> = {
|
const updates: Partial<ChartDataSource> = {
|
||||||
endpoint: fullEndpoint,
|
endpoint: fullEndpoint,
|
||||||
|
externalConnectionId: connectionId, // 외부 연결 ID 저장
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers: KeyValuePair[] = [];
|
const headers: KeyValuePair[] = [];
|
||||||
const queryParams: KeyValuePair[] = [];
|
const queryParams: KeyValuePair[] = [];
|
||||||
|
|
||||||
|
// 기본 메서드/바디가 있으면 적용
|
||||||
|
if (connection.default_method) {
|
||||||
|
updates.method = connection.default_method as ChartDataSource["method"];
|
||||||
|
}
|
||||||
|
if (connection.default_body) {
|
||||||
|
updates.body = connection.default_body;
|
||||||
|
}
|
||||||
|
|
||||||
// 기본 헤더가 있으면 적용
|
// 기본 헤더가 있으면 적용
|
||||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||||
|
|
@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bodyPayload =
|
||||||
|
dataSource.body && dataSource.body.trim().length > 0
|
||||||
|
? dataSource.body
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
method: dataSource.method || "GET",
|
method: dataSource.method || "GET",
|
||||||
headers,
|
headers,
|
||||||
queryParams,
|
queryParams,
|
||||||
|
body: bodyPayload,
|
||||||
|
externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP 메서드 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">HTTP 메서드</Label>
|
||||||
|
<Select
|
||||||
|
value={dataSource.method || "GET"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
method: value as ChartDataSource["method"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="GET" className="text-xs">
|
||||||
|
GET
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="POST" className="text-xs">
|
||||||
|
POST
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="PUT" className="text-xs">
|
||||||
|
PUT
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DELETE" className="text-xs">
|
||||||
|
DELETE
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="PATCH" className="text-xs">
|
||||||
|
PATCH
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Body (POST/PUT/PATCH 일 때만) */}
|
||||||
|
{(dataSource.method === "POST" ||
|
||||||
|
dataSource.method === "PUT" ||
|
||||||
|
dataSource.method === "PATCH") && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Request Body (선택)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={dataSource.body || ""}
|
||||||
|
onChange={(e) => onChange({ body: e.target.value })}
|
||||||
|
placeholder='{"key": "value"} 또는 원시 페이로드를 그대로 입력하세요'
|
||||||
|
className="h-24 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
이 내용은 그대로 외부 API 요청 Body로 전송됩니다. JSON이 아니어도 됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* JSON Path */}
|
{/* JSON Path */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,10 @@ export interface ChartDataSource {
|
||||||
|
|
||||||
// API 관련
|
// API 관련
|
||||||
endpoint?: string; // API URL
|
endpoint?: string; // API URL
|
||||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
// HTTP 메서드 (기본 GET, POST/PUT/DELETE/PATCH도 지원)
|
||||||
|
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
|
// 요청 Body (옵션) - 문자열 그대로 전송 (JSON 또는 일반 텍스트)
|
||||||
|
body?: string;
|
||||||
headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
|
headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
|
||||||
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
|
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
|
||||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export interface Dashboard {
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
createdByName?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
@ -97,6 +98,7 @@ export interface Dashboard {
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
elementsCount?: number;
|
elementsCount?: number;
|
||||||
creatorName?: string;
|
creatorName?: string;
|
||||||
|
companyCode?: string;
|
||||||
elements?: DashboardElement[];
|
elements?: DashboardElement[];
|
||||||
settings?: {
|
settings?: {
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ export interface ExternalApiConnection {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
endpoint_path?: string;
|
endpoint_path?: string;
|
||||||
default_headers: Record<string, string>;
|
default_headers: Record<string, string>;
|
||||||
|
// 기본 HTTP 메서드/바디 (외부 REST API 커넥션과 동일한 필드)
|
||||||
|
default_method?: string;
|
||||||
|
default_body?: string;
|
||||||
auth_type: AuthType;
|
auth_type: AuthType;
|
||||||
auth_config?: {
|
auth_config?: {
|
||||||
keyLocation?: "header" | "query";
|
keyLocation?: "header" | "query";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||||
|
|
@ -65,6 +66,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// 드롭다운 위치 (Portal 렌더링용)
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||||
const config = (props as any).webTypeConfig || componentConfig || {};
|
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||||
|
|
@ -326,9 +329,26 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
}, [selectedValue, codeOptions, config.options]);
|
}, [selectedValue, codeOptions, config.options]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
|
// 드롭다운 위치 계산 함수
|
||||||
|
const updateDropdownPosition = () => {
|
||||||
|
if (selectRef.current) {
|
||||||
|
const rect = selectRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isDesignMode) return;
|
if (isDesignMode) return;
|
||||||
|
|
||||||
|
// 드롭다운 열기 전에 위치 계산
|
||||||
|
if (!isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
|
||||||
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
|
|
@ -450,9 +470,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
value={searchQuery || selectedLabel}
|
value={searchQuery || selectedLabel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
updateDropdownPosition();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
|
||||||
placeholder="코드 또는 코드명 입력..."
|
placeholder="코드 또는 코드명 입력..."
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||||
|
|
@ -461,8 +485,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
readOnly={isDesignMode}
|
readOnly={isDesignMode}
|
||||||
/>
|
/>
|
||||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{filteredOptions.map((option, index) => (
|
{filteredOptions.map((option, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${option.value}-${index}`}
|
key={`${option.value}-${index}`}
|
||||||
|
|
@ -478,7 +510,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -508,8 +541,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isLoadingCodes ? (
|
{isLoadingCodes ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
|
|
@ -525,7 +566,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -590,9 +632,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
updateDropdownPosition();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
|
||||||
|
|
@ -601,8 +647,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
readOnly={isDesignMode}
|
readOnly={isDesignMode}
|
||||||
/>
|
/>
|
||||||
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{filteredOptions.map((option, index) => (
|
{filteredOptions.map((option, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${option.value}-${index}`}
|
key={`${option.value}-${index}`}
|
||||||
|
|
@ -620,7 +674,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
{option.label}
|
{option.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -650,8 +705,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|
@ -676,7 +739,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -693,7 +757,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
!isDesignMode && "hover:border-orange-400",
|
!isDesignMode && "hover:border-orange-400",
|
||||||
isSelected && "ring-2 ring-orange-500",
|
isSelected && "ring-2 ring-orange-500",
|
||||||
)}
|
)}
|
||||||
onClick={() => !isDesignMode && setIsOpen(true)}
|
onClick={() => {
|
||||||
|
if (!isDesignMode) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: isDesignMode ? "none" : "auto",
|
pointerEvents: isDesignMode ? "none" : "auto",
|
||||||
height: "100%"
|
height: "100%"
|
||||||
|
|
@ -726,22 +795,30 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<span className="text-gray-500">{placeholder}</span>
|
<span className="text-gray-500">{placeholder}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(isLoadingCodes || isLoadingCategories) ? (
|
{(isLoadingCodes || isLoadingCategories) ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
allOptions.map((option, index) => {
|
allOptions.map((option, index) => {
|
||||||
const isSelected = selectedValues.includes(option.value);
|
const isOptionSelected = selectedValues.includes(option.value);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${option.value}-${index}`}
|
key={`${option.value}-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||||
isSelected && "bg-blue-50 font-medium"
|
isOptionSelected && "bg-blue-50 font-medium"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newVals = isSelected
|
const newVals = isOptionSelected
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
: [...selectedValues, option.value];
|
: [...selectedValues, option.value];
|
||||||
setSelectedValues(newVals);
|
setSelectedValues(newVals);
|
||||||
|
|
@ -754,7 +831,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isOptionSelected}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
@ -766,7 +843,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -795,8 +873,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && !isDesignMode && (
|
{/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */}
|
||||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
{isOpen && !isDesignMode && typeof document !== "undefined" && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[99999] max-h-60 overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isLoadingCodes ? (
|
{isLoadingCodes ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
|
|
@ -812,7 +898,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue