feat: Optimize batch management and improve performance

- Reduced timeout for RestApiConnector to 20 seconds to ensure timely feedback for previews.
- Implemented caching for external DB metadata to minimize connection overhead and improve response times.
- Enhanced internal DB table and column retrieval with TTL caching to prevent database pool exhaustion.
- Introduced error handling for external DB timeouts, providing clearer feedback to users.
- Updated batch management UI to improve user experience with better error messages and streamlined data handling.

These changes aim to enhance the efficiency and reliability of batch management processes.
This commit is contained in:
kjs
2026-04-23 18:24:32 +09:00
parent c618283306
commit 4a9e3768a9
16 changed files with 305 additions and 216 deletions

View File

@@ -501,12 +501,13 @@ export class BatchManagementController {
});
// RestApiConnector 사용하여 데이터 조회
// 미리보기는 프론트 기본 30초 타임아웃 안에 응답해야 하므로 20초로 제한 — 실패 시 빠르게 피드백
const { RestApiConnector } = await import("../database/RestApiConnector");
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: finalApiKey,
timeout: 30000,
timeout: 20000,
});
// 연결 테스트
@@ -600,15 +601,15 @@ export class BatchManagementController {
}
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
// 미리보기 응답 CPU 부담 줄이기 위해 데이터 본문 로깅은 생략 (개수만 기록)
console.log(
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
data
`[previewRestApiData] 샘플 ${data.length}건 추출 (전체 ${extractedData.length}건)`
);
if (data.length > 0) {
// 첫 번째 객체에서 필드명 추출
const fields = Object.keys(data[0]);
console.log(`[previewRestApiData] 추출된 필드:`, fields);
console.log(`[previewRestApiData] 추출된 필드 수: ${fields.length}`);
return res.json({
success: true,

View File

@@ -5,6 +5,62 @@ import { query, queryOne } from "../database/db";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
// 외부 DB 메타데이터 TTL 캐시 — 같은 연결/테이블 반복 조회 시 커넥터 생성 오버헤드 회피
type CacheEntry<T> = { value: T; expiresAt: number };
const metadataCache = new Map<string, CacheEntry<unknown>>();
const METADATA_TTL_MS = 3 * 60 * 1000; // 3분
function cacheGet<T>(key: string): T | null {
const entry = metadataCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
metadataCache.delete(key);
return null;
}
return entry.value as T;
}
function cacheSet<T>(key: string, value: T, ttlMs = METADATA_TTL_MS): void {
metadataCache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
export function invalidateBatchMetadataCache(connectionId?: number): void {
if (connectionId === undefined) {
metadataCache.clear();
return;
}
const prefix = `:${connectionId}:`;
for (const key of metadataCache.keys()) {
if (key.includes(prefix)) metadataCache.delete(key);
}
}
// 외부 DB 메타데이터 조회 하드 타임아웃 — 응답 없는 DB로 인한 프론트 타임아웃(30초) 방지
const EXTERNAL_DB_TIMEOUT_MS = 15000;
class ExternalDbTimeoutError extends Error {
constructor(public readonly context: string, public readonly timeoutMs: number) {
super(`${context} (${timeoutMs / 1000}초 초과)`);
this.name = "ExternalDbTimeoutError";
}
}
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
context: string
): Promise<T> {
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new ExternalDbTimeoutError(context, timeoutMs));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutHandle) clearTimeout(timeoutHandle);
});
}
// 배치관리 전용 타입 정의
export interface BatchConnectionInfo {
type: "internal" | "external";
@@ -109,20 +165,27 @@ export class BatchManagementService {
let tables: BatchTableInfo[] = [];
if (connectionType === "internal") {
// 내부 DB 테이블 조회
const result = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[]
);
// 내부 DB 테이블 목록도 TTL 캐시 — 스키마는 자주 바뀌지 않음
const cacheKey = `int-tables`;
const cached = cacheGet<BatchTableInfo[]>(cacheKey);
if (cached) {
tables = cached;
} else {
const result = await query<{ table_name: string }>(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name`,
[]
);
tables = result.map((row) => ({
table_name: row.table_name,
columns: [],
}));
tables = result.map((row) => ({
table_name: row.table_name,
columns: [],
}));
cacheSet(cacheKey, tables);
}
} else if (connectionType === "external" && connectionId) {
// 외부 DB 테이블 조회 (회사별 필터링)
const tablesResult = await this.getExternalTables(
@@ -168,39 +231,38 @@ export class BatchManagementService {
let columns: BatchColumnInfo[] = [];
if (connectionType === "internal") {
// 내부 DB 컬럼 조회
console.log(
`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`
);
// 내부 DB 컬럼도 TTL 캐시 — information_schema 반복 조회로 DB 풀 고갈 방지
const cacheKey = `int-columns:${tableName}`;
const cached = cacheGet<BatchColumnInfo[]>(cacheKey);
if (cached) {
columns = cached;
} else {
const result = await query<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}>(
`SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position`,
[tableName]
);
const result = await query<{
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}>(
`SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position`,
[tableName]
);
console.log(`[BatchManagementService] 쿼리 결과:`, result);
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
columns = result.map((row) => ({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable,
column_default: row.column_default,
}));
columns = result.map((row) => ({
column_name: row.column_name,
data_type: row.data_type,
is_nullable: row.is_nullable,
column_default: row.column_default,
}));
cacheSet(cacheKey, columns);
}
} else if (connectionType === "external" && connectionId) {
// 외부 DB 컬럼 조회 (회사별 필터링)
console.log(
@@ -247,6 +309,17 @@ export class BatchManagementService {
userCompanyCode?: string
): Promise<BatchApiResponse<BatchTableInfo[]>> {
try {
// TTL 캐시 조회 (커넥터 생성/SSL 핸드쉐이크 오버헤드 회피)
const cacheKey = `ext-tables:${connectionId}:${userCompanyCode || "*"}`;
const cached = cacheGet<BatchTableInfo[]>(cacheKey);
if (cached) {
return {
success: true,
message: "테이블 목록을 조회했습니다. (cache)",
data: cached,
};
}
// 연결 정보 조회 (회사별 필터링)
let query_sql = `SELECT * FROM external_db_connections WHERE id = $1`;
const params: any[] = [connectionId];
@@ -296,13 +369,23 @@ export class BatchManagementService {
: false,
};
// DatabaseConnectorFactory를 통한 테이블 목록 조회
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
connectionId
// DatabaseConnectorFactory를 통한 테이블 목록 조회 (하드 타임아웃)
const connector = await withTimeout(
DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
connectionId
),
EXTERNAL_DB_TIMEOUT_MS,
`외부 DB '${connection.connection_name}' 연결 시간 초과`
);
const tables = await connector.getTables();
const tables = await withTimeout(
connector.getTables(),
EXTERNAL_DB_TIMEOUT_MS,
`외부 DB '${connection.connection_name}' 테이블 목록 조회 시간 초과`
);
cacheSet(cacheKey, tables);
return {
success: true,
@@ -311,6 +394,13 @@ export class BatchManagementService {
};
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
if (error instanceof ExternalDbTimeoutError) {
return {
success: false,
message: `${error.message}. 해당 DB 서버의 상태와 네트워크 연결을 확인해주세요.`,
error: error.message,
};
}
return {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
@@ -332,6 +422,20 @@ export class BatchManagementService {
`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`
);
// TTL 캐시 조회 (커넥터 생성/메타데이터 조회 오버헤드 회피)
const cacheKey = `ext-columns:${connectionId}:${tableName}:${userCompanyCode || "*"}`;
const cached = cacheGet<BatchColumnInfo[]>(cacheKey);
if (cached) {
console.log(
`[BatchManagementService] 캐시에서 컬럼 정보 반환: key=${cacheKey}, 개수=${cached.length}`
);
return {
success: true,
data: cached,
message: "컬럼 정보를 조회했습니다. (cache)",
};
}
// 연결 정보 조회 (회사별 필터링)
let query_sql = `SELECT * FROM external_db_connections WHERE id = $1`;
const params: any[] = [connectionId];
@@ -391,20 +495,28 @@ export class BatchManagementService {
`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`
);
// 데이터베이스 타입에 따른 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
connectionId
// 데이터베이스 타입에 따른 커넥터 생성 (하드 타임아웃)
const connector = await withTimeout(
DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
connectionId
),
EXTERNAL_DB_TIMEOUT_MS,
`외부 DB '${connection.connection_name}' 연결 시간 초과`
);
console.log(
`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`
);
// 컬럼 정보 조회
// 컬럼 정보 조회 (하드 타임아웃)
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
const columns = await connector.getColumns(tableName);
const columns = await withTimeout(
connector.getColumns(tableName),
EXTERNAL_DB_TIMEOUT_MS,
`외부 DB '${connection.connection_name}' 컬럼 조회 시간 초과`
);
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
console.log(
@@ -451,6 +563,8 @@ export class BatchManagementService {
standardizedColumns
);
cacheSet(cacheKey, standardizedColumns);
return {
success: true,
data: standardizedColumns,
@@ -461,6 +575,13 @@ export class BatchManagementService {
"[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:",
error
);
if (error instanceof ExternalDbTimeoutError) {
return {
success: false,
message: `${error.message}. 해당 DB 서버의 상태와 네트워크 연결을 확인해주세요.`,
error: error.message,
};
}
console.error(
"[BatchManagementService] 오류 스택:",
error instanceof Error ? error.stack : "No stack trace"

View File

@@ -72,13 +72,20 @@ export class BatchService {
const total = parseInt(countResult[0].count);
const totalPages = Math.ceil(total / limit);
// 목록 조회 (최근 실행 정보 포함)
// 목록 조회 — LATERAL JOIN으로 batch_execution_logs 최신 1건을 단일 스캔으로 조회 (N+1 방지)
const configs = await query<any>(
`SELECT bc.*,
(SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status,
(SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at,
(SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records
bel.execution_status as last_status,
bel.start_time as last_executed_at,
bel.total_records as last_total_records
FROM batch_configs bc
LEFT JOIN LATERAL (
SELECT execution_status, start_time, total_records
FROM batch_execution_logs
WHERE batch_config_id = bc.id
ORDER BY start_time DESC
LIMIT 1
) bel ON true
${whereClause}
ORDER BY bc.created_date DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,

View File

@@ -10,6 +10,7 @@ import {
} from "../types/externalDbTypes";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { invalidateBatchMetadataCache } from "./batchManagementService";
import logger from "../utils/logger";
export class ExternalDbConnectionService {
@@ -490,6 +491,9 @@ export class ExternalDbConnectionService {
updateParams
);
// 연결 정보가 바뀌었으므로 배치관리 메타데이터 캐시 무효화
invalidateBatchMetadataCache(id);
// 비밀번호는 반환하지 않음
const safeConnection = {
...updatedConnection,
@@ -541,6 +545,9 @@ export class ExternalDbConnectionService {
// 물리 삭제 (실제 데이터 삭제)
await query(`DELETE FROM external_db_connections WHERE id = $1`, [id]);
// 연결이 삭제되었으므로 배치관리 메타데이터 캐시 무효화
invalidateBatchMetadataCache(id);
logger.info(
`외부 DB 연결 삭제: ID ${id} (회사: ${userCompanyCode || "전체"})`
);

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -1211,7 +1211,7 @@ export function WorkStandardEditModal({
const openAddWorkItem = useCallback((phaseKey: string) => {
setAddItemPhase(phaseKey);
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemRequired("N");
setEditingWorkItem(null);
setAddItemOpen(true);
}, []);
@@ -1581,12 +1581,6 @@ export function WorkStandardEditModal({
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{item.details?.length || item.detail_count || 0}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
@@ -1766,10 +1760,6 @@ export function WorkStandardEditModal({
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>

View File

@@ -245,7 +245,7 @@ export default function BatchCreatePage() {
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
return (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
<div className="h-full w-full space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">

View File

@@ -88,11 +88,18 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
}
};
export default function BatchEditPage() {
const params = useParams();
interface BatchEditPageProps {
// 탭 렌더러(AdminPageRenderer → DynamicAdminLoader)에서 전달되는 params
adminParams?: { id?: string };
}
export default function BatchEditPage(props: BatchEditPageProps = {}) {
const urlParams = useParams();
const router = useRouter();
const { openTab } = useTabStore();
const batchId = parseInt(params.id as string);
// 탭에서 열린 경우 adminParams로 id가 전달됨. 직접 URL 접근 시 useParams() 사용
const batchIdStr = props.adminParams?.id ?? (urlParams.id as string | undefined) ?? "";
const batchId = parseInt(batchIdStr);
// 기본 상태
const [loading, setLoading] = useState(false);
@@ -176,10 +183,12 @@ export default function BatchEditPage() {
// 페이지 로드 시 배치 정보 조회
useEffect(() => {
if (batchId) {
if (Number.isFinite(batchId) && batchId > 0) {
loadBatchConfig();
loadConnections();
loadAuthServiceNames();
} else {
console.warn("[BatchEdit] 유효하지 않은 batchId:", batchIdStr, "- params를 확인하세요");
}
}, [batchId]);
@@ -407,42 +416,48 @@ export default function BatchEditPage() {
}
};
// 백엔드 상세 메시지 우선 추출 (외부 DB 타임아웃 등)
const extractErrorMessage = (error: unknown, fallback: string): string => {
const err = error as any;
return err?.response?.data?.message || err?.message || fallback;
};
// FROM 연결 변경 시
const handleFromConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) ||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setFromConnection(connection);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setFromTables(tables);
setFromTable("");
setFromColumns([]);
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
console.error("FROM 테이블 목록 조회 오류:", error);
toast.error(extractErrorMessage(error, "테이블 목록을 불러오는데 실패했습니다."));
}
}
};
// TO 연결 변경 시
const handleToConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) ||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
if (connection) {
setToConnection(connection);
try {
const tables = await BatchAPI.getTablesFromConnection(connection);
setToTables(tables);
setToTable("");
setToColumns([]);
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
console.error("TO 테이블 목록 조회 오류:", error);
toast.error(extractErrorMessage(error, "테이블 목록을 불러오는데 실패했습니다."));
}
}
};
@@ -450,14 +465,14 @@ export default function BatchEditPage() {
// FROM 테이블 변경 시
const handleFromTableChange = async (tableName: string) => {
setFromTable(tableName);
if (fromConnection && tableName) {
try {
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
setFromColumns(columns);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
console.error("FROM 컬럼 정보 조회 오류:", error);
toast.error(extractErrorMessage(error, "컬럼 정보를 불러오는데 실패했습니다."));
}
}
};
@@ -465,14 +480,14 @@ export default function BatchEditPage() {
// TO 테이블 변경 시
const handleToTableChange = async (tableName: string) => {
setToTable(tableName);
if (toConnection && tableName) {
try {
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
setToColumns(columns);
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
console.error("TO 컬럼 정보 조회 오류:", error);
toast.error(extractErrorMessage(error, "컬럼 정보를 불러오는데 실패했습니다."));
}
}
};
@@ -739,7 +754,7 @@ export default function BatchEditPage() {
}
return (
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
<div className="h-full w-full space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
@@ -760,11 +775,34 @@ export default function BatchEditPage() {
</div>
<p className="mt-1 text-xs text-muted-foreground">#{batchId} </p>
</div>
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => { loadBatchConfig(); loadConnections(); loadAuthServiceNames(); }}
disabled={loading}
className="h-8 gap-1 text-xs"
title="배치 설정을 다시 불러옵니다"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
</div>
</div>
{/* 매핑이 비어 있으면 안내 */}
{batchConfig && (!batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) && executionType !== "node_flow" && (
<div className="mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="font-semibold text-amber-600 dark:text-amber-400"> </p>
<p className="mt-0.5 text-muted-foreground">
. [] .
</p>
</div>
)}
</div>
{/* 기본 정보 */}

View File

@@ -338,13 +338,26 @@ export default function BatchManagementPage() {
]);
if (configsResponse.success && configsResponse.data) {
setBatchConfigs(configsResponse.data);
// 각 배치의 스파크라인 백그라운드 로드
// 스파크라인 백그라운드 로드 — 동시 요청 3개로 제한하여 DB 풀/타임아웃 방지
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
const cache: Record<number, SparklineData[]> = {};
results.forEach(r => { cache[r.id] = r.data; });
setSparklineCache(prev => ({ ...prev, ...cache }));
});
(async () => {
const CONCURRENCY = 3;
for (let i = 0; i < ids.length; i += CONCURRENCY) {
const chunk = ids.slice(i, i + CONCURRENCY);
const results = await Promise.all(
chunk.map(id =>
BatchAPI.getBatchSparkline(id)
.then(data => ({ id, data }))
.catch(() => ({ id, data: [] as SparklineData[] }))
)
);
setSparklineCache(prev => {
const next = { ...prev };
results.forEach(r => { next[r.id] = r.data; });
return next;
});
}
})();
} else {
setBatchConfigs([]);
}
@@ -445,8 +458,8 @@ export default function BatchManagementPage() {
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
<div className="h-full overflow-y-auto bg-background">
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
{/* 헤더 */}
<div className="flex items-center justify-between">

View File

@@ -65,7 +65,6 @@ export function WorkItemAddModal({
selectedItemCode,
}: WorkItemAddModalProps) {
const [title, setTitle] = useState("");
const [isRequired, setIsRequired] = useState("Y");
const [description, setDescription] = useState("");
const [details, setDetails] = useState<ModalDetail[]>([]);
const [detailFormOpen, setDetailFormOpen] = useState(false);
@@ -73,11 +72,9 @@ export function WorkItemAddModal({
useEffect(() => {
if (open && editItem) {
setTitle(editItem.title || "");
setIsRequired(editItem.is_required || "Y");
setDescription(editItem.description || "");
} else if (open && !editItem) {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
}
@@ -85,7 +82,6 @@ export function WorkItemAddModal({
const resetForm = () => {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
};
@@ -95,7 +91,7 @@ export function WorkItemAddModal({
onSave({
work_phase: phaseKey,
title: title.trim(),
is_required: isRequired,
is_required: "N",
description: description.trim() || undefined,
details: details
.filter((d) => d.content.trim())
@@ -163,34 +159,16 @@ export function WorkItemAddModal({
<p className="text-xs font-medium text-muted-foreground">
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 장비 점검, 품질 검사"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={isRequired} onValueChange={setIsRequired}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm">
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 장비 점검, 품질 검사"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs"></Label>

View File

@@ -48,12 +48,6 @@ export function WorkItemCard({
>
{item.detail_count}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>