From 5949ea22b542271a7b1c8e323dcd784a638835f4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 12:13:40 +0900 Subject: [PATCH 01/23] Enhance Sonner Toast styles for improved user experience. Implement various toast types (success, error, warning, info) with distinct visual cues. Update layout to utilize the new toast component from the UI library, ensuring consistent styling across the application. --- docs/kjs/배치_노드플로우_연동_계획서.md | 355 ++++++++++++++++++ frontend/app/globals.css | 126 ++++++- frontend/app/layout.tsx | 4 +- frontend/components/ui/sonner.tsx | 28 ++ .../v2-table-list/TableListComponent.tsx | 6 +- 5 files changed, 506 insertions(+), 13 deletions(-) create mode 100644 docs/kjs/배치_노드플로우_연동_계획서.md create mode 100644 frontend/components/ui/sonner.tsx diff --git a/docs/kjs/배치_노드플로우_연동_계획서.md b/docs/kjs/배치_노드플로우_연동_계획서.md new file mode 100644 index 00000000..f3a03d7d --- /dev/null +++ b/docs/kjs/배치_노드플로우_연동_계획서.md @@ -0,0 +1,355 @@ +# 배치 스케줄러 + 노드 플로우 연동 계획서 + +## 1. 배경 및 목적 + +### 현재 상태 + +현재 시스템에는 두 개의 독립적인 실행 엔진이 있다: + +| 시스템 | 역할 | 트리거 방식 | +|--------|------|-------------| +| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) | +| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) | + +### 문제 + +- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함 +- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수) +- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음 + +### 목표 + +배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여, +시간 기반 비즈니스 로직 자동화를 지원한다. + +``` +[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진] + │ │ + │ ├── 테이블 소스 조회 + │ ├── 조건 분기 + │ ├── UPDATE / DELETE / INSERT + │ ├── 이메일 발송 + │ └── 로깅 + │ + └── 실행 로그 기록 (batch_execution_logs) +``` + +--- + +## 2. 사용 시나리오 + +### 시나리오 A: 자동 퇴사 처리 + +``` +매일 자정 실행: + 1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회 + 2. 해당 사용자의 상태를 '퇴사'로 UPDATE + 3. 관리자에게 이메일 알림 발송 +``` + +### 시나리오 B: 월말 재고 마감 + +``` +매월 1일 00:00 실행: + 1. 전월 재고 데이터를 재고마감 테이블로 INSERT + 2. 이월 수량 계산 후 UPDATE +``` + +### 시나리오 C: 미납 알림 + +``` +매일 09:00 실행: + 1. 납기일이 지난 미납 주문 조회 + 2. 담당자에게 이메일 발송 + 3. 알림 로그 INSERT +``` + +### 시나리오 D: 외부 API 연동 자동화 + +``` +매시간 실행: + 1. 외부 REST API에서 데이터 조회 + 2. 조건 필터링 (변경된 데이터만) + 3. 내부 테이블에 UPSERT +``` + +--- + +## 3. 구현 범위 + +### 3.1 DB 변경 (batch_configs 테이블 확장) + +```sql +-- batch_configs 테이블에 컬럼 추가 +ALTER TABLE batch_configs + ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping', + ADD COLUMN node_flow_id INTEGER DEFAULT NULL, + ADD COLUMN node_flow_context JSONB DEFAULT NULL; + +-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행) +-- node_flow_id: node_flows 테이블의 flow_id (FK) +-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택) + +COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)'; +COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)'; +COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)'; +``` + +기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장) + +### 3.2 백엔드 변경 + +#### BatchSchedulerService 수정 (핵심) + +`executeBatchConfig()` 메서드에서 `execution_type` 분기: + +``` +executeBatchConfig(config) + ├── config.execution_type === 'mapping' + │ └── 기존 executeBatchMappings() (변경 없음) + │ + └── config.execution_type === 'node_flow' + └── NodeFlowExecutionService.executeFlow() + ├── 노드 플로우 조회 + ├── 위상 정렬 + ├── 레벨별 실행 + └── 결과 반환 +``` + +수정 파일: +- `backend-node/src/services/batchSchedulerService.ts` + - `executeBatchConfig()` 에 node_flow 분기 추가 + - 노드 플로우 실행 결과를 배치 로그 형식으로 변환 + +#### 배치 설정 API 수정 + +수정 파일: +- `backend-node/src/types/batchTypes.ts` + - `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가 + - `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가 +- `backend-node/src/services/batchService.ts` + - `createBatchConfig()` - 새 필드 INSERT + - `updateBatchConfig()` - 새 필드 UPDATE +- `backend-node/src/controllers/batchManagementController.ts` + - 생성/수정 시 새 필드 처리 + +#### 노드 플로우 목록 API (배치용) + +추가 파일/수정: +- `backend-node/src/routes/batchManagementRoutes.ts` + - `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용) + +### 3.3 프론트엔드 변경 + +#### 배치 생성/편집 UI 수정 + +수정 파일: +- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` +- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` + +변경 내용: +- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우) +- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김) +- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON) + +``` +┌─────────────────────────────────────────┐ +│ 배치 설정 │ +├─────────────────────────────────────────┤ +│ 배치명: [자동 퇴사 처리 ] │ +│ 설명: [퇴사일 경과 사용자 자동 처리] │ +│ Cron: [0 0 * * * ] │ +│ │ +│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │ +│ │ +│ ┌─ 노드 플로우 선택 ─────────────────┐ │ +│ │ [▾ 자동 퇴사 처리 플로우 ] │ │ +│ │ │ │ +│ │ 플로우 설명: user_info에서 퇴사일..│ │ +│ │ 노드 수: 4개 │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ [취소] [저장] │ +└─────────────────────────────────────────┘ +``` + +#### 배치 목록 UI 수정 + +수정 파일: +- `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` + +변경 내용: +- 목록에 "실행 타입" 컬럼 추가 (배지: `매핑` / `플로우`) +- 노드 플로우 타입은 연결된 플로우명 표시 + +--- + +## 4. 변경 파일 목록 + +### DB + +| 파일 | 변경 | 설명 | +|------|------|------| +| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs | + +### 백엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 | +| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 | +| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 | +| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API 처리 | +| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows 목록 엔드포인트 추가 | + +### 프론트엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | 수정 | 목록에 실행 타입 표시 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | + +--- + +## 5. 핵심 구현 상세 + +### 5.1 BatchSchedulerService 변경 (가장 중요) + +```typescript +// batchSchedulerService.ts - executeBatchConfig 메서드 수정 + +static async executeBatchConfig(config: any) { + const startTime = new Date(); + let executionLog: any = null; + + try { + // ... 실행 로그 생성 (기존 코드 유지) ... + + let result; + + // 실행 타입에 따라 분기 + if (config.execution_type === 'node_flow' && config.node_flow_id) { + // 노드 플로우 실행 + result = await this.executeNodeFlow(config); + } else { + // 기존 매핑 실행 (하위 호환) + result = await this.executeBatchMappings(config); + } + + // ... 실행 로그 업데이트 (기존 코드 유지) ... + return result; + } catch (error) { + // ... 에러 처리 (기존 코드 유지) ... + } +} + +/** + * 노드 플로우 실행 (신규) + */ +private static async executeNodeFlow(config: any) { + const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService'); + + const context = { + sourceData: [], + dataSourceType: 'none', + nodeResults: new Map(), + executionOrder: [], + buttonContext: { + buttonId: `batch_${config.id}`, + companyCode: config.company_code, + userId: config.created_by || 'batch_system', + formData: config.node_flow_context || {}, + }, + }; + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + context + ); + + // 노드 플로우 결과를 배치 로그 형식으로 변환 + return { + totalRecords: flowResult.totalNodes || 0, + successRecords: flowResult.successNodes || 0, + failedRecords: flowResult.failedNodes || 0, + }; +} +``` + +### 5.2 실행 결과 매핑 + +노드 플로우 결과 → 배치 로그 변환: + +| 노드 플로우 결과 | 배치 로그 필드 | 설명 | +|------------------|---------------|------| +| 전체 노드 수 | total_records | 실행 대상 노드 수 | +| 성공 노드 수 | success_records | 성공적으로 실행된 노드 | +| 실패 노드 수 | failed_records | 실패한 노드 | +| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 | + +### 5.3 보안 고려사항 + +- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용 +- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함 +- 최고 관리자(`*`)는 모든 플로우 실행 가능 +- 실행 로그에 `batch_system`으로 사용자 기록 + +--- + +## 6. 구현 순서 + +### Phase 1: DB + 백엔드 (1일) + +1. 마이그레이션 SQL 작성 및 실행 +2. `batchTypes.ts` 타입 수정 +3. `batchService.ts` create/update 수정 +4. `batchSchedulerService.ts` 핵심 분기 로직 추가 +5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가 +6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`) + +### Phase 2: 프론트엔드 (1일) + +1. 배치 생성 페이지에 실행 타입 선택 추가 +2. 노드 플로우 드롭다운 구현 +3. 배치 편집 페이지 동일 적용 +4. 배치 목록에 실행 타입 배지 표시 + +### Phase 3: 테스트 및 검증 (0.5일) + +1. 테스트용 노드 플로우 생성 (간단한 UPDATE) +2. 배치 설정에 연결 +3. 수동 실행 테스트 +4. Cron 스케줄 자동 실행 테스트 +5. 실행 로그 확인 + +--- + +## 7. 리스크 및 대응 + +### 7.1 노드 플로우 실행 시간 초과 + +- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음 +- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려 + +### 7.2 노드 플로우 삭제 시 배치 깨짐 + +- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패 +- **대응**: + - 플로우 존재 여부 체크 후 실행 + - 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록 + - (향후) 플로우 삭제 시 연결된 배치가 있으면 경고 + +### 7.3 멀티 인스턴스 환경 + +- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행 +- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려 + +--- + +## 8. 기대 효과 + +1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리 +2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진 +3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장 +4. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 19097f43..f1e6383a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -306,16 +306,126 @@ select { } } -/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ -[data-sonner-toaster] [data-sonner-toast] { - animation: none !important; - transition: none !important; - opacity: 1 !important; - transform: none !important; +/* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */ + +/* 기본 토스트: 다크 배경 스낵바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar { + --normal-bg: hsl(222 30% 16%); + --normal-text: hsl(210 20% 92%); + --normal-border: hsl(222 20% 24%); + + background: var(--normal-bg); + color: var(--normal-text); + border: 1px solid var(--normal-border); + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + gap: 10px; } -[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] { - animation: none !important; + +/* 다크모드 토스트 */ +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar { + --normal-bg: hsl(220 25% 14%); + --normal-border: hsl(220 20% 22%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } + +/* 성공 토스트 - 좌측 초록 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-success { + --success-bg: hsl(222 30% 16%); + --success-text: hsl(210 20% 92%); + --success-border: hsl(222 20% 24%); + + background: var(--success-bg) !important; + color: var(--success-text) !important; + border: 1px solid var(--success-border) !important; + border-left: 3px solid hsl(142 76% 42%) !important; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-success { + --success-bg: hsl(220 25% 14%) !important; + --success-border: hsl(220 20% 22%) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +/* 에러 토스트 - 좌측 빨간 바 + 약간 붉은 배경 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-error { + --error-bg: hsl(0 30% 14%); + --error-text: hsl(0 20% 92%); + --error-border: hsl(0 20% 22%); + + background: var(--error-bg) !important; + color: var(--error-text) !important; + border: 1px solid var(--error-border) !important; + border-left: 3px solid hsl(0 72% 55%) !important; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-error { + --error-bg: hsl(0 25% 10%) !important; + --error-border: hsl(0 20% 18%) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +/* 경고 토스트 - 좌측 노란 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-warning { + background: hsl(222 30% 16%) !important; + color: hsl(210 20% 92%) !important; + border: 1px solid hsl(222 20% 24%) !important; + border-left: 3px solid hsl(38 92% 50%) !important; + border-radius: 10px; +} + +/* info 토스트 - 좌측 파란 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-info { + background: hsl(222 30% 16%) !important; + color: hsl(210 20% 92%) !important; + border: 1px solid hsl(222 20% 24%) !important; + border-left: 3px solid hsl(217 91% 60%) !important; + border-radius: 10px; +} + +/* 토스트 내부 설명 텍스트 */ +[data-sonner-toaster] [data-sonner-toast] [data-description] { + color: hsl(210 15% 70%) !important; + font-size: 12px !important; +} + +/* 토스트 닫기 버튼: 토스트 안쪽 우측 상단 배치 */ +[data-sonner-toaster] [data-sonner-toast] [data-close-button] { + position: absolute !important; + top: 50% !important; + right: 8px !important; + left: auto !important; + transform: translateY(-50%) !important; + width: 20px !important; + height: 20px !important; + background: transparent !important; + border: none !important; + border-radius: 4px !important; + color: hsl(210 15% 55%) !important; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; +} +[data-sonner-toaster] [data-sonner-toast] [data-close-button]:hover { + background: hsl(220 20% 24%) !important; + color: hsl(210 20% 85%) !important; + opacity: 1; +} + +/* 토스트 액션 버튼 */ +[data-sonner-toaster] [data-sonner-toast] [data-button] { + color: hsl(217 91% 68%) !important; + font-weight: 700; + font-size: 12px; +} + +/* 애니메이션 제어: 부드러운 슬라이드 업만 허용, 나머지 제거 */ [data-sonner-toaster] [data-sonner-toast][data-removed="true"] { animation: none !important; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2c5f1fa3..c57c6504 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { QueryProvider } from "@/providers/QueryProvider"; import { RegistryProvider } from "./registry-provider"; -import { Toaster } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; const inter = Inter({ subsets: ["latin"], @@ -45,7 +45,7 @@ export default function RootLayout({ {children} - + {/* Portal 컨테이너 */} diff --git a/frontend/components/ui/sonner.tsx b/frontend/components/ui/sonner.tsx new file mode 100644 index 00000000..0911ead1 --- /dev/null +++ b/frontend/components/ui/sonner.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as SonnerToaster } from "sonner"; + +export function Toaster() { + const { theme = "system" } = useTheme(); + + return ( + + ); +} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 31cabb70..616ef121 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -6317,7 +6317,7 @@ export const TableListComponent: React.FC = ({ "hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75", index % 2 === 0 ? "bg-background" : "bg-muted/20", isRowSelected && "!bg-primary/10 hover:!bg-primary/15", - isRowFocused && "ring-primary/50 ring-1 ring-inset", + isRowFocused && "bg-accent/50", isDragEnabled && "cursor-grab active:cursor-grabbing", isDragging && "bg-muted opacity-50", isDropTarget && "border-t-primary border-t-2", @@ -6381,10 +6381,10 @@ export const TableListComponent: React.FC = ({ inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", - isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", + isCellFocused && !editingCell && "bg-primary/5 shadow-[inset_0_0_0_2px_hsl(var(--primary))]", editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", - cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", + cellValidationError && "bg-red-50 shadow-[inset_0_0_0_2px_hsl(0_84%_60%)] dark:bg-red-950/40", isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", column.editable === false && "bg-muted/10 dark:bg-muted/10", // 코드 컬럼: mono 폰트 + primary 색상 From 8e4791c57ae10adc924b5dea02fdcd2f203aae25 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 13:51:25 +0900 Subject: [PATCH 02/23] [agent-pipeline] pipe-20260318044621-56k5 round-1 --- docs/kjs/배치_노드플로우_연동_계획서.md | 588 +++++++++++++++++++++++- 1 file changed, 571 insertions(+), 17 deletions(-) diff --git a/docs/kjs/배치_노드플로우_연동_계획서.md b/docs/kjs/배치_노드플로우_연동_계획서.md index f3a03d7d..97630229 100644 --- a/docs/kjs/배치_노드플로우_연동_계획서.md +++ b/docs/kjs/배치_노드플로우_연동_계획서.md @@ -173,14 +173,524 @@ executeBatchConfig(config) └─────────────────────────────────────────┘ ``` -#### 배치 목록 UI 수정 +#### 배치 목록 UI - Ops 대시보드 리디자인 -수정 파일: -- `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` +현재 배치 목록은 단순 테이블인데, Vercel/Railway 스타일의 **운영 대시보드**로 전면 리디자인한다. +노드 플로우 연동과 함께 적용하면 새로운 실행 타입도 자연스럽게 표현 가능. -변경 내용: -- 목록에 "실행 타입" 컬럼 추가 (배지: `매핑` / `플로우`) -- 노드 플로우 타입은 연결된 플로우명 표시 +디자인 컨셉: **"편집기"가 아닌 "운영 대시보드"** +- 데이터 타입 관리 = 컬럼 편집기 → 3패널(리스트/그리드/설정)이 적합 +- 배치 관리 = 운영 모니터링 → 테이블 + 인라인 상태 표시가 적합 +- 역할이 다르면 레이아웃도 달라야 함 + +--- + +##### 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [헤더] 배치 관리 [새로고침] [새 배치] │ +│ └ 데이터 동기화 배치 작업을 모니터링하고 관리합니다 │ +├──────────────────────────────────────────────────────────────┤ +│ [통계 카드 4열 그리드] │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 전체 배치 │ │ 활성 배치 │ │ 오늘 실행 │ │ 오늘 실패 │ │ +│ │ 8 │ │ 6 │ │ 142 │ │ 3 │ │ +│ │ +2 이번달│ │ 2 비활성 │ │+12% 전일 │ │+1 전일 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────────────────────┤ +│ [툴바] │ +│ 🔍 검색... [전체|활성|비활성] [전체|DB-DB|API-DB|플로우] 총 8건 │ +├──────────────────────────────────────────────────────────────┤ +│ [테이블 헤더] │ +│ ● 배치 타입 스케줄 최근24h 마지막실행 │ +├──────────────────────────────────────────────────────────────┤ +│ ● 품목 마스터 동기화 DB→DB */30**** ▌▌▌▐▌▌▌ 14:30 ▶✎🗑 │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ [확장 상세 패널 - 클릭 시 토글] │ │ +│ │ 내러티브 + 파이프라인 + 매핑 + 설정 + 타임라인 │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ● 거래처 ERP 연동 API→DB 0*/2*** ▌▌▌▌▌▌▌ 14:00 ▶✎🗑 │ +│ ◉ 재고 현황 수집 API→DB 06,18** ▌▌▐▌▌▌░ 실행중 ▶✎🗑 │ +│ ○ BOM 백업 DB→DB 0 3**0 ░░░░░░░ 비활성 ▶✎🗑 │ +│ ... │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +##### 1. 페이지 헤더 + +``` +구조: flex, align-items: flex-end, justify-content: space-between +하단 보더: 1px solid border +하단 마진: 24px + +좌측: + - 제목: "배치 관리" (text-xl font-extrabold tracking-tight) + - 부제: "데이터 동기화 배치 작업을 모니터링하고 관리합니다" (text-xs text-muted-foreground) + +우측 버튼 그룹 (gap-2): + - [새로고침] 버튼: variant="outline", RefreshCw 아이콘 + - [새 배치] 버튼: variant="default" (primary), Plus 아이콘 +``` + +--- + +##### 2. 통계 카드 영역 + +``` +레이아웃: grid grid-cols-4 gap-3 +각 카드: rounded-xl border bg-card p-4 + +카드 구조: + ┌──────────────────────────┐ + │ [라벨] [아이콘] │ ← stat-top: flex justify-between + │ │ + │ 숫자값 (28px 모노 볼드) │ ← stat-val: font-mono text-3xl font-extrabold + │ │ + │ [변화량 배지] 기간 텍스트 │ ← stat-footer: flex items-center gap-1.5 + └──────────────────────────┘ + +4개 카드 상세: +┌─────────────┬────────────┬───────────────────────────────┐ +│ 카드 │ 아이콘 색상 │ 값 색상 │ +├─────────────┼────────────┼───────────────────────────────┤ +│ 전체 배치 │ indigo bg │ foreground (기본) │ +│ 활성 배치 │ green bg │ green (--success) │ +│ 오늘 실행 │ cyan bg │ cyan (--info 계열) │ +│ 오늘 실패 │ red bg │ red (--destructive) │ +└─────────────┴────────────┴───────────────────────────────┘ + +변화량 배지: + - 증가: green 배경 + green 텍스트, "+N" 또는 "+N%" + - 감소/악화: red 배경 + red 텍스트 + - 크기: text-[10px] font-bold px-1.5 py-0.5 rounded + +아이콘 박스: 28x28px rounded-lg, 배경색 투명도 10% +아이콘: lucide-react (LayoutGrid, CheckCircle, Activity, XCircle) +``` + +**데이터 소스:** +``` +GET /api/batch-management/stats +→ { + totalBatches: number, // batch_configs COUNT(*) + activeBatches: number, // batch_configs WHERE is_active='Y' + todayExecutions: number, // batch_execution_logs WHERE DATE(start_time)=TODAY + todayFailures: number, // batch_execution_logs WHERE DATE(start_time)=TODAY AND status='FAILED' + // 선택사항: 전일 대비 변화량 + prevDayExecutions?: number, + prevDayFailures?: number + } +``` + +--- + +##### 3. 툴바 + +``` +레이아웃: flex items-center gap-2.5 + +요소 1 - 검색: + - 위치: 좌측, flex-1 max-w-[320px] + - 구조: relative div + input + Search 아이콘(absolute left) + - input: h-9, rounded-lg, border, bg-card, text-xs + - placeholder: "배치 이름으로 검색..." + - focus: ring-2 ring-primary + +요소 2 - 상태 필터 (pill-group): + - 컨테이너: flex gap-0.5, bg-card, border, rounded-lg, p-0.5 + - 각 pill: text-[11px] font-semibold px-3 py-1.5 rounded-md + - 활성 pill: bg-primary/10 text-primary + - 비활성 pill: text-muted-foreground, hover시 밝아짐 + - 항목: [전체] [활성] [비활성] + +요소 3 - 타입 필터 (pill-group): + - 동일 스타일 + - 항목: [전체] [DB-DB] [API-DB] [노드 플로우] ← 노드 플로우는 신규 + +요소 4 - 건수 표시: + - 위치: ml-auto (우측 정렬) + - 텍스트: "총 N건" (text-[11px] text-muted-foreground, N은 font-bold) +``` + +--- + +##### 4. 배치 테이블 + +``` +컨테이너: border rounded-xl overflow-hidden bg-card + +테이블 헤더: + - 배경: bg-muted/50 + - 높이: 40px + - 글자: text-[10px] font-bold text-muted-foreground uppercase tracking-wider + - 그리드 컬럼: 44px 1fr 100px 130px 160px 100px 120px + - 컬럼: [LED] [배치] [타입] [스케줄] [최근 24h] [마지막 실행] [액션] +``` + +--- + +##### 5. 배치 테이블 행 (핵심) + +``` +그리드: 44px 1fr 100px 130px 160px 100px 120px +높이: min-height 60px +하단 보더: 1px solid border +hover: bg-card/80 (약간 밝아짐) +선택됨: bg-primary/10 + 좌측 3px primary 박스 섀도우 (inset) +클릭 시: 상세 패널 토글 + +[셀 1] LED 상태 표시: + ┌──────────────────────────────────────┐ + │ 원형 8x8px, 센터 정렬 │ + │ │ + │ 활성(on): green + box-shadow glow │ + │ 실행중(run): amber + 1.5s blink 애니 │ + │ 비활성(off): muted-foreground (회색) │ + │ 에러(err): red + box-shadow glow │ + └──────────────────────────────────────┘ + +[셀 2] 배치 정보: + ┌──────────────────────────────────────┐ + │ 배치명: text-[13px] font-bold │ + │ 설명: text-[10px] text-muted-fg │ + │ overflow ellipsis (1줄) │ + │ │ + │ 비활성 배치: 배치명도 muted 색상 │ + └──────────────────────────────────────┘ + +[셀 3] 타입 배지: + ┌──────────────────────────────────────┐ + │ inline-flex, text-[10px] font-bold │ + │ px-2 py-0.5 rounded-[5px] │ + │ │ + │ DB → DB: cyan 배경/텍스트 │ + │ API → DB: violet 배경/텍스트 │ + │ 노드 플로우: indigo 배경/텍스트 (신규) │ + └──────────────────────────────────────┘ + +[셀 4] Cron 스케줄: + ┌──────────────────────────────────────┐ + │ Cron 표현식: font-mono text-[11px] │ + │ font-medium │ + │ 한글 설명: text-[9px] text-muted │ + │ "매 30분", "매일 01:00" │ + │ │ + │ 비활성: muted 색상 │ + └──────────────────────────────────────┘ + + Cron → 한글 변환 예시: + - */30 * * * * → "매 30분" + - 0 */2 * * * → "매 2시간" + - 0 6,18 * * * → "06:00, 18:00" + - 0 1 * * * → "매일 01:00" + - 0 3 * * 0 → "매주 일 03:00" + - 0 0 1 * * → "매월 1일 00:00" + +[셀 5] 스파크라인 (최근 24h): + ┌──────────────────────────────────────┐ + │ flex, items-end, gap-[1px], h-6 │ + │ │ + │ 24개 바 (시간당 1개): │ + │ - 성공(ok): green, opacity 60% │ + │ - 실패(fail): red, opacity 80% │ + │ - 미실행(none): muted, opacity 15% │ + │ │ + │ 각 바: flex-1, min-w-[3px] │ + │ rounded-t-[1px] │ + │ 높이: 실행시간 비례 또는 고정 │ + │ hover: opacity 100% │ + └──────────────────────────────────────┘ + + 데이터: 최근 24시간을 1시간 단위로 슬라이싱 + 각 슬롯별 가장 최근 실행의 status 사용 + 높이: 성공=80~95%, 실패=20~40%, 미실행=5% + +[셀 6] 마지막 실행: + ┌──────────────────────────────────────┐ + │ 시간: font-mono text-[10px] │ + │ "14:30:00" │ + │ 경과: text-[9px] muted │ + │ "12분 전" │ + │ │ + │ 실행 중: amber 색상 "실행 중..." │ + │ 비활성: muted "-" + "비활성" │ + └──────────────────────────────────────┘ + +[셀 7] 액션 버튼: + ┌──────────────────────────────────────┐ + │ flex gap-1, justify-end │ + │ │ + │ 3개 아이콘 버튼 (28x28 rounded-md): │ + │ │ + │ [▶] 수동 실행 │ + │ hover: green 테두리+배경+아이콘 │ + │ 아이콘: Play (lucide) │ + │ │ + │ [✎] 편집 │ + │ hover: 기본 밝아짐 │ + │ 아이콘: Pencil (lucide) │ + │ │ + │ [🗑] 삭제 │ + │ hover: red 테두리+배경+아이콘 │ + │ 아이콘: Trash2 (lucide) │ + └──────────────────────────────────────┘ +``` + +--- + +##### 6. 행 확장 상세 패널 (클릭 시 토글) + +행을 클릭하면 아래로 펼쳐지는 상세 패널. 매핑 타입과 노드 플로우 타입에 따라 내용이 달라진다. + +``` +컨테이너: + - border (상단 border 없음, 행과 이어짐) + - rounded-b-xl + - bg-muted/30 (행보다 약간 어두운 배경) + - padding: 20px 24px + +내부 구조: + ┌────────────────────────────────────────────────────────────┐ + │ [내러티브 박스] │ + │ "ERP_SOURCE DB의 item_master 테이블에서 현재 DB의 │ + │ item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 │ + │ 있어요. 오늘 48회 실행, 마지막 실행은 12분 전이에요." │ + ├────────────────────────────────────────────────────────────┤ + │ [파이프라인 플로우 다이어그램] │ + │ │ + │ ┌─────────────┐ 12 컬럼 UPSERT ┌─────────────┐ │ + │ │ 🗄 DB아이콘 │ ─────────────────→ │ 🗄 DB아이콘 │ │ + │ │ ERP_SOURCE │ WHERE USE_YN='Y' │ 현재 DB │ │ + │ │ item_master │ │ item_info │ │ + │ └─────────────┘ └─────────────┘ │ + ├──────────────────────┬─────────────────────────────────────┤ + │ [좌측: 매핑 + 설정] │ [우측: 실행 이력 타임라인] │ + │ │ │ + │ --- 컬럼 매핑 (12) --- │ --- 실행 이력 (최근 5건) --- │ + │ ITEM_CD → item_code PK│ ● 14:30:00 [성공] 1,842건 3.2s │ + │ ITEM_NM → item_name │ │ │ + │ ITEM_SPEC → spec... │ ● 14:00:00 [성공] 1,840건 3.1s │ + │ UNIT_CD → unit_code │ │ │ + │ STD_PRICE → std_price │ ✕ 13:30:00 [실패] Timeout │ + │ + 7개 더 보기 │ │ │ + │ │ ● 13:00:00 [성공] 1,838건 2.9s │ + │ --- 설정 --- │ │ │ + │ 배치 크기: 500 │ ● 12:30:00 [성공] 1,835건 3.5s │ + │ 타임아웃: 30s │ │ + │ 실패 시: 3회 재시도 │ │ + │ 매칭 키: item_code │ │ + │ 모드: [UPSERT] │ │ + └──────────────────────┴─────────────────────────────────────┘ +``` + +**6-1. 내러티브 박스 (Toss 스타일 자연어 설명)** + +``` +스타일: + - rounded-lg + - 배경: linear-gradient(135deg, primary/6%, info/4%) + - 보더: 1px solid primary/8% + - padding: 12px 14px + - margin-bottom: 16px + +텍스트: text-[11px] text-muted-foreground leading-relaxed +강조 텍스트: + - 굵은 텍스트(b): foreground font-semibold + - 하이라이트(hl): primary font-bold + +매핑 타입 예시: + "ERP_SOURCE 데이터베이스의 item_master 테이블에서 현재 DB의 + item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 있어요. + 오늘 48회 실행, 마지막 실행은 12분 전이에요." + +노드 플로우 타입 예시: + "자동 퇴사 처리 노드 플로우를 매일 00:00에 실행하고 있어요. + user_info 테이블에서 퇴사일이 지난 사용자를 조회하여 + 상태를 '퇴사'로 변경합니다. 4개 노드로 구성되어 있어요." +``` + +**6-2. 파이프라인 플로우 다이어그램** + +``` +컨테이너: + - flex items-center + - rounded-lg border bg-card p-4 + - margin-bottom: 16px + +구조: [소스 노드] ──[커넥터]──> [타겟 노드] + +소스 노드 (pipe-node src): + - 배경: cyan/6%, 보더: cyan/12% + - 아이콘: 32x32 rounded-lg, cyan/12% 배경 + - DB 타입: Database 아이콘 (lucide) + - API 타입: Cloud 아이콘 (lucide) + violet 색상 + - 이름: text-xs font-bold cyan 색상 + - 부제: font-mono text-[10px] muted (테이블명/URL) + +커넥터 (pipe-connector): + - flex-1, flex-col items-center + - 상단 라벨: text-[9px] font-bold muted ("12 컬럼 UPSERT") + - 라인: width 100%, h-[2px], gradient(cyan → green) + - 라인 끝: 삼각형 화살표 (CSS ::after) + - 하단 라벨: text-[9px] font-bold muted ("WHERE USE_YN='Y'") + +타겟 노드 (pipe-node tgt): + - 배경: green/6%, 보더: green/12% + - 아이콘: green/12% 배경 + - 이름: text-xs font-bold green 색상 + - 부제: 테이블명 + +노드 플로우 타입일 때: + - 소스/타겟 대신 노드 플로우 요약 카드로 대체 + - 아이콘: Workflow 아이콘 (lucide) + indigo 색상 + - 이름: 플로우명 + - 부제: "N개 노드 | 조건 분기 포함" + - 노드 목록: 간략 리스트 (Source → Condition → Update → Email) +``` + +**6-3. 하단 2열 그리드** + +``` +레이아웃: grid grid-cols-2 gap-5 + +[좌측 컬럼] 매핑 + 설정: + + 섹션 1 - 컬럼 매핑: + 헤더: flex items-center gap-1.5 + - Link 아이콘 (lucide, 13px, muted) + - "컬럼 매핑" (text-[11px] font-bold muted) + - 건수 배지 (ml-auto, text-[9px] font-bold, primary/10% bg, primary 색) + + 매핑 행 (map-row): + - flex items-center gap-1.5 + - rounded-md border bg-card px-2.5 py-1.5 + - margin-bottom: 2px + + 구조: [소스 컬럼] → [타겟 컬럼] [태그] + 소스: font-mono font-semibold text-[11px] cyan + 화살표: "→" muted + 타겟: font-mono font-semibold text-[11px] green + 태그: text-[8px] font-bold px-1.5 py-0.5 rounded-sm + PK = green 배경 + dark 텍스트 + + 5개까지 표시 후 "+ N개 더 보기" 접기/펼치기 + + 노드 플로우 타입일 때: + 매핑 대신 "노드 구성" 섹션으로 대체 + 각 행: [노드 아이콘] [노드 타입] [노드 설명] + 예: 🔍 테이블 소스 | user_info 조회 + 🔀 조건 분기 | 퇴사일 <= NOW() + ✏️ UPDATE | status → '퇴사' + 📧 이메일 | 관리자 알림 + + 섹션 2 - 설정 (cprop 리스트): + 헤더: Settings 아이콘 + "설정" + + 각 행 (cprop): + - flex justify-between py-1.5 + - 하단 보더: 1px solid white/3% + - 키: text-[11px] muted + - 값: text-[11px] font-semibold, mono체는 font-mono text-[10px] + - 특수 값: UPSERT 배지 (green/10% bg, green 색, text-[10px] font-bold) + + 매핑 타입 설정: + - 배치 크기: 500 + - 타임아웃: 30s + - 실패 시 재시도: 3회 (green) + - 매칭 키: item_code (mono) + - 모드: [UPSERT] (배지) + + 노드 플로우 타입 설정: + - 플로우 ID: 42 + - 노드 수: 4개 + - 실행 타임아웃: 60s + - 컨텍스트: { ... } (mono, 접기 가능) + + +[우측 컬럼] 실행 이력 타임라인: + + 헤더: Clock 아이콘 + "실행 이력" + "최근 5건" 배지 (green) + + 타임라인 (timeline): + flex-col, gap-0 + + 각 항목 (tl-item): + - flex items-start gap-3 + - padding: 10px 0 + - 하단 보더: 1px solid white/3% + + 좌측 - 점+선 (tl-dot-wrap): + - flex-col items-center, width 16px + - 점 (tl-dot): 8x8 rounded-full + 성공(ok): green + 실패(fail): red + 실행중(run): amber + blink 애니메이션 + - 선 (tl-line): width 1px, bg border, min-h 12px + 마지막 항목에는 선 없음 + + 우측 - 내용 (tl-body): + - 시간: font-mono text-[10px] font-semibold + - 상태 배지: text-[9px] font-bold px-1.5 py-0.5 rounded + 성공: green/10% bg + green 색 + 실패: red/10% bg + red 색 + - 메시지: text-[10px] muted, margin-top 2px + 성공: "1,842건 처리 / 3.2s 소요" + 실패: "Connection timeout: ERP_SOURCE 응답 없음" +``` + +--- + +##### 7. 반응형 대응 + +``` +1024px 이하 (태블릿): + - 통계 카드: grid-cols-2 + - 테이블 그리드: 36px 1fr 80px 110px 120px 80px (액션 숨김) + - 상세 패널 2열 그리드 → 1열 + +640px 이하 (모바일): + - 컨테이너 padding: 16px + - 통계 카드: grid-cols-2 gap-2 + - 테이블 헤더: 숨김 + - 테이블 행: grid-cols-1, 카드형태 (padding 16px, gap 8px) +``` + +--- + +##### 8. 필요한 백엔드 API + +``` +1. GET /api/batch-management/stats + → { + totalBatches: number, + activeBatches: number, + todayExecutions: number, + todayFailures: number, + prevDayExecutions?: number, + prevDayFailures?: number + } + 쿼리: batch_configs COUNT + batch_execution_logs 오늘/어제 집계 + +2. GET /api/batch-management/batch-configs/:id/sparkline + → [{ hour: 0~23, status: 'success'|'failed'|'none', count: number }] + 쿼리: batch_execution_logs WHERE batch_config_id=$1 + AND start_time >= NOW() - INTERVAL '24 hours' + GROUP BY EXTRACT(HOUR FROM start_time) + +3. GET /api/batch-management/batch-configs/:id/recent-logs?limit=5 + → [{ start_time, end_time, execution_status, total_records, + success_records, failed_records, error_message, duration_ms }] + 쿼리: batch_execution_logs WHERE batch_config_id=$1 + ORDER BY start_time DESC LIMIT $2 + +4. GET /api/batch-management/batch-configs (기존 수정) + → 각 배치에 sparkline 요약 + last_execution 포함하여 반환 + 목록 페이지에서 개별 sparkline API를 N번 호출하지 않도록 + 한번에 가져오기 (LEFT JOIN + 서브쿼리) +``` --- @@ -199,14 +709,14 @@ executeBatchConfig(config) | `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 | | `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 | | `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 | -| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API 처리 | -| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows 목록 엔드포인트 추가 | +| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API + stats/sparkline/recent-logs API | +| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows/stats/sparkline 엔드포인트 추가 | ### 프론트엔드 | 파일 | 변경 | 설명 | |------|------|------| -| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | 수정 | 목록에 실행 타입 표시 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | **리디자인** | Ops 대시보드 스타일로 전면 재작성 | | `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | | `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | @@ -299,7 +809,7 @@ private static async executeNodeFlow(config: any) { ## 6. 구현 순서 -### Phase 1: DB + 백엔드 (1일) +### Phase 1: DB + 백엔드 코어 (1일) 1. 마이그레이션 SQL 작성 및 실행 2. `batchTypes.ts` 타입 수정 @@ -308,20 +818,33 @@ private static async executeNodeFlow(config: any) { 5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가 6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`) -### Phase 2: 프론트엔드 (1일) +### Phase 2: 백엔드 대시보드 API (0.5일) -1. 배치 생성 페이지에 실행 타입 선택 추가 -2. 노드 플로우 드롭다운 구현 -3. 배치 편집 페이지 동일 적용 -4. 배치 목록에 실행 타입 배지 표시 +1. `GET /api/batch-management/stats` - 전체/활성/오늘실행/오늘실패 집계 API +2. `GET /api/batch-management/batch-configs/:id/sparkline` - 최근 24h 실행 결과 (시간대별 성공/실패/미실행) +3. `GET /api/batch-management/batch-configs/:id/recent-logs?limit=5` - 최근 N건 실행 이력 +4. 기존 목록 API에 sparkline 요약 데이터 포함 옵션 추가 -### Phase 3: 테스트 및 검증 (0.5일) +### Phase 3: 프론트엔드 - 배치 목록 Ops 대시보드 (1.5일) + +상세 UI 명세는 위 "3.3 배치 목록 UI - Ops 대시보드 리디자인" 섹션 참조. + +1. **페이지 헤더**: 제목 + 부제 + 새로고침/새배치 버튼 (명세 항목 1) +2. **통계 카드 영역**: 4개 카드 + stats API 연동 (명세 항목 2) +3. **툴바**: 검색 + 상태/타입 필터 pill-group + 건수 표시 (명세 항목 3) +4. **배치 테이블**: 7열 그리드 헤더 + 행 (명세 항목 4~5) +5. **행 확장 상세 패널**: 내러티브 + 파이프라인 + 매핑/플로우 + 설정 + 타임라인 (명세 항목 6) +6. **반응형**: 1024px/640px 브레이크포인트 (명세 항목 7) +7. 배치 생성/편집 모달에 실행 타입 선택 + 노드 플로우 드롭다운 + +### Phase 4: 테스트 및 검증 (0.5일) 1. 테스트용 노드 플로우 생성 (간단한 UPDATE) 2. 배치 설정에 연결 3. 수동 실행 테스트 4. Cron 스케줄 자동 실행 테스트 5. 실행 로그 확인 +6. 대시보드 통계/스파크라인 정확성 확인 --- @@ -352,4 +875,35 @@ private static async executeNodeFlow(config: any) { 1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리 2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진 3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장 -4. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능 +4. **운영 가시성 극대화**: Ops 대시보드로 배치 상태/건강도를 한눈에 파악 (스파크라인, LED, 타임라인) +5. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능 + +--- + +## 9. 설계 의도 - 왜 기존 화면과 다른 레이아웃인가 + +| 비교 항목 | 데이터 타입 관리 (편집기) | 배치 관리 (대시보드) | +|-----------|------------------------|-------------------| +| 역할 | 컬럼 메타데이터 편집 | 운영 상태 모니터링 | +| 레이아웃 | 3패널 (리스트/그리드/설정) | 테이블 + 인라인 모니터링 | +| 주요 행위 | 필드 추가/삭제/수정 | 상태 확인, 수동 실행, 이력 조회 | +| 시각적 요소 | 폼, 드래그앤드롭 | LED, 스파크라인, 타임라인 | +| 참고 UI | IDE, Figma 속성 패널 | Vercel Functions, Railway | + +### 디자인 키포인트 6가지 + +1. **스파크라인 = 건강 상태 한눈에**: Vercel의 Function 목록처럼 각 배치 행에 최근 24h 실행 결과를 미니 바 차트로 표현. 숫자 읽을 필요 없이 패턴으로 건강 상태 파악. + +2. **Expandable Row 패턴**: 3패널 대신 클릭하면 행이 확장되어 상세 정보 표시. 파이프라인 플로우 + 매핑 + 타임라인이 한 번에. Railway/GitHub Actions의 Job 상세 패턴. + +3. **LED 상태 표시**: 카드의 Badge(활성/비활성) 대신 LED 점으로 상태 표현. 초록=활성, 주황깜빡임=실행중, 회색=비활성. 운영실 모니터 느낌. + +4. **파이프라인 플로우 다이어그램**: 소스 → 화살표 → 타겟을 수평 파이프라인으로 시각화. DB-DB는 DB 아이콘 둘, API-DB는 클라우드+DB. 데이터 흐름이 직관적. + +5. **내러티브 박스**: 설정값을 나열하는 대신 자연어로 요약. "A에서 B로 N개 컬럼을 매 30분마다 동기화하고 있어요" 식. Toss 스타일 UX Writing. + +6. **타임라인 실행 이력**: 테이블 로그 대신 세로 타임라인(점+선). 성공/실패가 시각적으로 즉시 구분. 문제 발생 시점 빠르게 특정 가능. + +### 디자인 원본 + +HTML 프리뷰 파일: `_local/batch-management-v3-preview.html` (브라우저에서 열어 시각적 확인 가능) From 351e57dd3104f6d46dd5a1004cb326af9c82a043 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 13:56:03 +0900 Subject: [PATCH 03/23] [agent-pipeline] pipe-20260318044621-56k5 round-2 --- .../controllers/batchManagementController.ts | 37 ++++++++++++ .../src/routes/batchManagementRoutes.ts | 6 ++ .../src/services/batchSchedulerService.ts | 60 ++++++++++++++++++- backend-node/src/services/batchService.ts | 25 +++++++- backend-node/src/types/batchTypes.ts | 12 ++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdd9e869..4d406209 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,4 +768,41 @@ export class BatchManagementController { }); } } + + /** + * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) + * GET /api/batch-management/node-flows + * 멀티테넌시: 최고 관리자는 전체, 일반 회사는 자기 회사 플로우만 + */ + static async getNodeFlows(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + let queryText: string; + let queryParams: any[] = []; + + if (companyCode === "*") { + queryText = `SELECT flow_id, flow_name, description, created_date + FROM node_flows + ORDER BY flow_name`; + } else { + queryText = `SELECT flow_id, flow_name, description, created_date + FROM node_flows + WHERE company_code = $1 + ORDER BY flow_name`; + queryParams = [companyCode]; + } + + const result = await query(queryText, queryParams); + + return res.json({ success: true, data: result }); + } catch (error) { + console.error("노드 플로우 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "노드 플로우 목록 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 50ee1ea0..d08664de 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -85,4 +85,10 @@ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveR */ router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); +/** + * GET /api/batch-management/node-flows + * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) + */ +router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); + export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index f6fe56a1..b3aef16d 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -165,8 +165,20 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) - const result = await this.executeBatchMappings(config); + // 실행 유형에 따라 분기: node_flow면 노드 플로우 실행, 아니면 매핑 배치 실행 + let result: { + totalRecords: number; + successRecords: number; + failedRecords: number; + }; + if ( + config.execution_type === "node_flow" && + config.node_flow_id != null + ) { + result = await this.executeNodeFlow(config); + } else { + result = await this.executeBatchMappings(config); + } // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { @@ -207,6 +219,50 @@ export class BatchSchedulerService { } } + /** + * 노드 플로우 실행 (execution_type === 'node_flow'일 때) + * node_flows 테이블의 플로우를 NodeFlowExecutionService로 실행하고 결과를 배치 로그 형식으로 반환 + */ + private static async executeNodeFlow(config: any): Promise<{ + totalRecords: number; + successRecords: number; + failedRecords: number; + }> { + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + // 플로우 존재 여부 확인 + const flowCheck = await query<{ flow_id: number; flow_name: string }>( + "SELECT flow_id, flow_name FROM node_flows WHERE flow_id = $1", + [config.node_flow_id] + ); + if (flowCheck.length === 0) { + throw new Error( + `노드 플로우를 찾을 수 없습니다 (flow_id: ${config.node_flow_id})` + ); + } + + const contextData: Record = { + ...(config.node_flow_context || {}), + _batchId: config.id, + _batchName: config.batch_name, + _companyCode: config.company_code, + _executedBy: "batch_system", + }; + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + contextData + ); + + return { + totalRecords: flowResult.summary.total, + successRecords: flowResult.summary.success, + failedRecords: flowResult.summary.failed, + }; + } + /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 31ee2001..5c4d7def 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,6 +189,11 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, + data.executionType || "mapping", + data.nodeFlowId ?? null, + data.nodeFlowContext != null + ? JSON.stringify(data.nodeFlowContext) + : null, userId, ] ); @@ -332,6 +337,22 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } + if (data.executionType !== undefined) { + updateFields.push(`execution_type = $${paramIndex++}`); + updateValues.push(data.executionType); + } + if (data.nodeFlowId !== undefined) { + updateFields.push(`node_flow_id = $${paramIndex++}`); + updateValues.push(data.nodeFlowId ?? null); + } + if (data.nodeFlowContext !== undefined) { + updateFields.push(`node_flow_context = $${paramIndex++}`); + updateValues.push( + data.nodeFlowContext != null + ? JSON.stringify(data.nodeFlowContext) + : null + ); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index a6404036..efaca16f 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -91,6 +91,12 @@ export interface BatchConfig { conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) + /** 실행 유형: mapping(테이블 매핑) | node_flow(노드 플로우) */ + execution_type?: "mapping" | "node_flow"; + /** 노드 플로우 실행 시 사용할 flow_id (node_flows.flow_id) */ + node_flow_id?: number; + /** 노드 플로우 실행 시 전달할 컨텍스트 (Record) */ + node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; @@ -150,6 +156,9 @@ export interface CreateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + executionType?: "mapping" | "node_flow"; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -162,6 +171,9 @@ export interface UpdateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + executionType?: "mapping" | "node_flow"; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } From 360a9ab1aa305ab8a005728a1a0118118df8887e Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 13:56:03 +0900 Subject: [PATCH 04/23] [agent-pipeline] rollback to 8e4791c5 --- .../controllers/batchManagementController.ts | 37 ------------ .../src/routes/batchManagementRoutes.ts | 6 -- .../src/services/batchSchedulerService.ts | 60 +------------------ backend-node/src/services/batchService.ts | 25 +------- backend-node/src/types/batchTypes.ts | 12 ---- 5 files changed, 4 insertions(+), 136 deletions(-) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 4d406209..bdd9e869 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,41 +768,4 @@ export class BatchManagementController { }); } } - - /** - * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) - * GET /api/batch-management/node-flows - * 멀티테넌시: 최고 관리자는 전체, 일반 회사는 자기 회사 플로우만 - */ - static async getNodeFlows(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - - let queryText: string; - let queryParams: any[] = []; - - if (companyCode === "*") { - queryText = `SELECT flow_id, flow_name, description, created_date - FROM node_flows - ORDER BY flow_name`; - } else { - queryText = `SELECT flow_id, flow_name, description, created_date - FROM node_flows - WHERE company_code = $1 - ORDER BY flow_name`; - queryParams = [companyCode]; - } - - const result = await query(queryText, queryParams); - - return res.json({ success: true, data: result }); - } catch (error) { - console.error("노드 플로우 목록 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "노드 플로우 목록 조회 실패", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index d08664de..50ee1ea0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -85,10 +85,4 @@ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveR */ router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); -/** - * GET /api/batch-management/node-flows - * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) - */ -router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); - export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index b3aef16d..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -165,20 +165,8 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실행 유형에 따라 분기: node_flow면 노드 플로우 실행, 아니면 매핑 배치 실행 - let result: { - totalRecords: number; - successRecords: number; - failedRecords: number; - }; - if ( - config.execution_type === "node_flow" && - config.node_flow_id != null - ) { - result = await this.executeNodeFlow(config); - } else { - result = await this.executeBatchMappings(config); - } + // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) + const result = await this.executeBatchMappings(config); // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { @@ -219,50 +207,6 @@ export class BatchSchedulerService { } } - /** - * 노드 플로우 실행 (execution_type === 'node_flow'일 때) - * node_flows 테이블의 플로우를 NodeFlowExecutionService로 실행하고 결과를 배치 로그 형식으로 반환 - */ - private static async executeNodeFlow(config: any): Promise<{ - totalRecords: number; - successRecords: number; - failedRecords: number; - }> { - const { NodeFlowExecutionService } = await import( - "./nodeFlowExecutionService" - ); - - // 플로우 존재 여부 확인 - const flowCheck = await query<{ flow_id: number; flow_name: string }>( - "SELECT flow_id, flow_name FROM node_flows WHERE flow_id = $1", - [config.node_flow_id] - ); - if (flowCheck.length === 0) { - throw new Error( - `노드 플로우를 찾을 수 없습니다 (flow_id: ${config.node_flow_id})` - ); - } - - const contextData: Record = { - ...(config.node_flow_context || {}), - _batchId: config.id, - _batchName: config.batch_name, - _companyCode: config.company_code, - _executedBy: "batch_system", - }; - - const flowResult = await NodeFlowExecutionService.executeFlow( - config.node_flow_id, - contextData - ); - - return { - totalRecords: flowResult.summary.total, - successRecords: flowResult.summary.success, - failedRecords: flowResult.summary.failed, - }; - } - /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 5c4d7def..31ee2001 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,11 +189,6 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, - data.executionType || "mapping", - data.nodeFlowId ?? null, - data.nodeFlowContext != null - ? JSON.stringify(data.nodeFlowContext) - : null, userId, ] ); @@ -337,22 +332,6 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } - if (data.executionType !== undefined) { - updateFields.push(`execution_type = $${paramIndex++}`); - updateValues.push(data.executionType); - } - if (data.nodeFlowId !== undefined) { - updateFields.push(`node_flow_id = $${paramIndex++}`); - updateValues.push(data.nodeFlowId ?? null); - } - if (data.nodeFlowContext !== undefined) { - updateFields.push(`node_flow_context = $${paramIndex++}`); - updateValues.push( - data.nodeFlowContext != null - ? JSON.stringify(data.nodeFlowContext) - : null - ); - } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index efaca16f..a6404036 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -91,12 +91,6 @@ export interface BatchConfig { conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) - /** 실행 유형: mapping(테이블 매핑) | node_flow(노드 플로우) */ - execution_type?: "mapping" | "node_flow"; - /** 노드 플로우 실행 시 사용할 flow_id (node_flows.flow_id) */ - node_flow_id?: number; - /** 노드 플로우 실행 시 전달할 컨텍스트 (Record) */ - node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; @@ -156,9 +150,6 @@ export interface CreateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 - executionType?: "mapping" | "node_flow"; - nodeFlowId?: number; - nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -171,9 +162,6 @@ export interface UpdateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 - executionType?: "mapping" | "node_flow"; - nodeFlowId?: number; - nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } From 609460cd8d118ba24e17fe1d0c61a5b4ef996641 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:00:44 +0900 Subject: [PATCH 05/23] [agent-pipeline] pipe-20260318044621-56k5 round-3 --- .../controllers/batchManagementController.ts | 38 +++++++++ .../src/routes/batchManagementRoutes.ts | 6 ++ .../src/services/batchSchedulerService.ts | 77 ++++++++++++++++++- backend-node/src/services/batchService.ts | 25 +++++- backend-node/src/types/batchTypes.ts | 12 +++ 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdd9e869..bdc41e46 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,4 +768,42 @@ export class BatchManagementController { }); } } + + /** + * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) + * GET /api/batch-management/node-flows + * 멀티테넌시: 최고 관리자는 전체, 일반 회사는 자기 회사 플로우만 + */ + static async getNodeFlows(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + let queryText: string; + let queryParams: any[] = []; + + if (companyCode === "*") { + queryText = `SELECT flow_id, flow_name, flow_description AS description, created_at AS created_date + FROM node_flows + ORDER BY flow_name`; + } else { + queryText = `SELECT flow_id, flow_name, flow_description AS description, created_at AS created_date + FROM node_flows + WHERE company_code = $1 + ORDER BY flow_name`; + queryParams = [companyCode ?? ""]; + } + + const result = await query(queryText, queryParams); + const data = Array.isArray(result) ? result : []; + + return res.json({ success: true, data }); + } catch (error) { + console.error("노드 플로우 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "노드 플로우 목록 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 50ee1ea0..d08664de 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -85,4 +85,10 @@ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveR */ router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); +/** + * GET /api/batch-management/node-flows + * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) + */ +router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); + export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index f6fe56a1..f912e5ad 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -165,8 +165,20 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) - const result = await this.executeBatchMappings(config); + // 실행 유형 분기: node_flow면 노드 플로우 실행, 아니면 매핑 배치 실행 + let result: { + totalRecords: number; + successRecords: number; + failedRecords: number; + }; + if ( + config.execution_type === "node_flow" && + config.node_flow_id != null + ) { + result = await this.executeNodeFlow(config); + } else { + result = await this.executeBatchMappings(config); + } // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { @@ -207,6 +219,67 @@ export class BatchSchedulerService { } } + /** + * 노드 플로우 실행 (execution_type === 'node_flow'일 때) + * node_flows 테이블의 플로우를 NodeFlowExecutionService로 실행하고 결과를 배치 로그 형식으로 반환 + */ + private static async executeNodeFlow(config: any): Promise<{ + totalRecords: number; + successRecords: number; + failedRecords: number; + }> { + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + // 플로우 존재 여부 확인 + const flowCheck = await query<{ flow_id: number; flow_name: string }>( + "SELECT flow_id, flow_name FROM node_flows WHERE flow_id = $1", + [config.node_flow_id] + ); + if (flowCheck.length === 0) { + throw new Error( + `노드 플로우를 찾을 수 없습니다 (flow_id: ${config.node_flow_id})` + ); + } + + // node_flow_context: DB JSONB는 객체로 올 수 있고, 문자열로 올 수 있음. 안전 파싱 + let contextObj: Record = {}; + if (config.node_flow_context != null) { + if (typeof config.node_flow_context === "string") { + try { + contextObj = JSON.parse(config.node_flow_context); + } catch { + contextObj = {}; + } + } else if ( + typeof config.node_flow_context === "object" && + !Array.isArray(config.node_flow_context) + ) { + contextObj = { ...config.node_flow_context }; + } + } + + const contextData: Record = { + ...contextObj, + _batchId: config.id, + _batchName: config.batch_name, + _companyCode: config.company_code, + _executedBy: "batch_system", + }; + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + contextData + ); + + return { + totalRecords: flowResult.summary.total, + successRecords: flowResult.summary.success, + failedRecords: flowResult.summary.failed, + }; + } + /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 31ee2001..5c4d7def 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,6 +189,11 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, + data.executionType || "mapping", + data.nodeFlowId ?? null, + data.nodeFlowContext != null + ? JSON.stringify(data.nodeFlowContext) + : null, userId, ] ); @@ -332,6 +337,22 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } + if (data.executionType !== undefined) { + updateFields.push(`execution_type = $${paramIndex++}`); + updateValues.push(data.executionType); + } + if (data.nodeFlowId !== undefined) { + updateFields.push(`node_flow_id = $${paramIndex++}`); + updateValues.push(data.nodeFlowId ?? null); + } + if (data.nodeFlowContext !== undefined) { + updateFields.push(`node_flow_context = $${paramIndex++}`); + updateValues.push( + data.nodeFlowContext != null + ? JSON.stringify(data.nodeFlowContext) + : null + ); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index a6404036..efaca16f 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -91,6 +91,12 @@ export interface BatchConfig { conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) + /** 실행 유형: mapping(테이블 매핑) | node_flow(노드 플로우) */ + execution_type?: "mapping" | "node_flow"; + /** 노드 플로우 실행 시 사용할 flow_id (node_flows.flow_id) */ + node_flow_id?: number; + /** 노드 플로우 실행 시 전달할 컨텍스트 (Record) */ + node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; @@ -150,6 +156,9 @@ export interface CreateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + executionType?: "mapping" | "node_flow"; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -162,6 +171,9 @@ export interface UpdateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 + executionType?: "mapping" | "node_flow"; + nodeFlowId?: number; + nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } From ab477abf8bd602d12730fef8bf101891a5089c7d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:03:34 +0900 Subject: [PATCH 06/23] [agent-pipeline] rollback to 609460cd --- .../controllers/batchManagementController.ts | 38 --------- .../src/routes/batchManagementRoutes.ts | 6 -- .../src/services/batchSchedulerService.ts | 77 +------------------ backend-node/src/services/batchService.ts | 25 +----- backend-node/src/types/batchTypes.ts | 12 --- 5 files changed, 4 insertions(+), 154 deletions(-) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdc41e46..bdd9e869 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,42 +768,4 @@ export class BatchManagementController { }); } } - - /** - * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) - * GET /api/batch-management/node-flows - * 멀티테넌시: 최고 관리자는 전체, 일반 회사는 자기 회사 플로우만 - */ - static async getNodeFlows(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - - let queryText: string; - let queryParams: any[] = []; - - if (companyCode === "*") { - queryText = `SELECT flow_id, flow_name, flow_description AS description, created_at AS created_date - FROM node_flows - ORDER BY flow_name`; - } else { - queryText = `SELECT flow_id, flow_name, flow_description AS description, created_at AS created_date - FROM node_flows - WHERE company_code = $1 - ORDER BY flow_name`; - queryParams = [companyCode ?? ""]; - } - - const result = await query(queryText, queryParams); - const data = Array.isArray(result) ? result : []; - - return res.json({ success: true, data }); - } catch (error) { - console.error("노드 플로우 목록 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "노드 플로우 목록 조회 실패", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index d08664de..50ee1ea0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -85,10 +85,4 @@ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveR */ router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); -/** - * GET /api/batch-management/node-flows - * 노드 플로우 목록 조회 (배치 설정에서 노드 플로우 선택용) - */ -router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows); - export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index f912e5ad..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -165,20 +165,8 @@ export class BatchSchedulerService { executionLog = executionLogResponse.data; - // 실행 유형 분기: node_flow면 노드 플로우 실행, 아니면 매핑 배치 실행 - let result: { - totalRecords: number; - successRecords: number; - failedRecords: number; - }; - if ( - config.execution_type === "node_flow" && - config.node_flow_id != null - ) { - result = await this.executeNodeFlow(config); - } else { - result = await this.executeBatchMappings(config); - } + // 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용) + const result = await this.executeBatchMappings(config); // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { @@ -219,67 +207,6 @@ export class BatchSchedulerService { } } - /** - * 노드 플로우 실행 (execution_type === 'node_flow'일 때) - * node_flows 테이블의 플로우를 NodeFlowExecutionService로 실행하고 결과를 배치 로그 형식으로 반환 - */ - private static async executeNodeFlow(config: any): Promise<{ - totalRecords: number; - successRecords: number; - failedRecords: number; - }> { - const { NodeFlowExecutionService } = await import( - "./nodeFlowExecutionService" - ); - - // 플로우 존재 여부 확인 - const flowCheck = await query<{ flow_id: number; flow_name: string }>( - "SELECT flow_id, flow_name FROM node_flows WHERE flow_id = $1", - [config.node_flow_id] - ); - if (flowCheck.length === 0) { - throw new Error( - `노드 플로우를 찾을 수 없습니다 (flow_id: ${config.node_flow_id})` - ); - } - - // node_flow_context: DB JSONB는 객체로 올 수 있고, 문자열로 올 수 있음. 안전 파싱 - let contextObj: Record = {}; - if (config.node_flow_context != null) { - if (typeof config.node_flow_context === "string") { - try { - contextObj = JSON.parse(config.node_flow_context); - } catch { - contextObj = {}; - } - } else if ( - typeof config.node_flow_context === "object" && - !Array.isArray(config.node_flow_context) - ) { - contextObj = { ...config.node_flow_context }; - } - } - - const contextData: Record = { - ...contextObj, - _batchId: config.id, - _batchName: config.batch_name, - _companyCode: config.company_code, - _executedBy: "batch_system", - }; - - const flowResult = await NodeFlowExecutionService.executeFlow( - config.node_flow_id, - contextData - ); - - return { - totalRecords: flowResult.summary.total, - successRecords: flowResult.summary.success, - failedRecords: flowResult.summary.failed, - }; - } - /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 5c4d7def..31ee2001 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -189,11 +189,6 @@ export class BatchService { data.conflictKey || null, data.authServiceName || null, data.dataArrayPath || null, - data.executionType || "mapping", - data.nodeFlowId ?? null, - data.nodeFlowContext != null - ? JSON.stringify(data.nodeFlowContext) - : null, userId, ] ); @@ -337,22 +332,6 @@ export class BatchService { updateFields.push(`data_array_path = $${paramIndex++}`); updateValues.push(data.dataArrayPath || null); } - if (data.executionType !== undefined) { - updateFields.push(`execution_type = $${paramIndex++}`); - updateValues.push(data.executionType); - } - if (data.nodeFlowId !== undefined) { - updateFields.push(`node_flow_id = $${paramIndex++}`); - updateValues.push(data.nodeFlowId ?? null); - } - if (data.nodeFlowContext !== undefined) { - updateFields.push(`node_flow_context = $${paramIndex++}`); - updateValues.push( - data.nodeFlowContext != null - ? JSON.stringify(data.nodeFlowContext) - : null - ); - } // 배치 설정 업데이트 const batchConfigResult = await client.query( diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index efaca16f..a6404036 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -91,12 +91,6 @@ export interface BatchConfig { conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) - /** 실행 유형: mapping(테이블 매핑) | node_flow(노드 플로우) */ - execution_type?: "mapping" | "node_flow"; - /** 노드 플로우 실행 시 사용할 flow_id (node_flows.flow_id) */ - node_flow_id?: number; - /** 노드 플로우 실행 시 전달할 컨텍스트 (Record) */ - node_flow_context?: Record; created_by?: string; created_date?: Date; updated_by?: string; @@ -156,9 +150,6 @@ export interface CreateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 - executionType?: "mapping" | "node_flow"; - nodeFlowId?: number; - nodeFlowContext?: Record; mappings: BatchMappingRequest[]; } @@ -171,9 +162,6 @@ export interface UpdateBatchConfigRequest { conflictKey?: string; authServiceName?: string; dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 - executionType?: "mapping" | "node_flow"; - nodeFlowId?: number; - nodeFlowContext?: Record; mappings?: BatchMappingRequest[]; } From 577e9c12d1266816680b0232290f2baad46dfa4c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:07:14 +0900 Subject: [PATCH 07/23] [agent-pipeline] pipe-20260318044621-56k5 round-5 --- .../controllers/batchManagementController.ts | 234 ++++++++++++++++++ .../src/routes/batchManagementRoutes.ts | 19 ++ 2 files changed, 253 insertions(+) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdd9e869..b2dc1e8c 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,4 +768,238 @@ export class BatchManagementController { }); } } + + /** + * 배치 대시보드 통계 조회 + * GET /api/batch-management/stats + * totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchStats(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + + // 전체/활성 배치 수 + let configQuery: string; + let configParams: any[] = []; + if (companyCode === "*") { + configQuery = ` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active + FROM batch_configs + `; + } else { + configQuery = ` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active + FROM batch_configs + WHERE company_code = $1 + `; + configParams = [companyCode]; + } + const configResult = await query<{ total: number; active: number }>( + configQuery, + configParams + ); + + // 오늘/어제 실행·실패 수 (KST 기준 날짜) + const logParams: any[] = []; + let logWhere = ""; + if (companyCode && companyCode !== "*") { + logWhere = " AND company_code = $1"; + logParams.push(companyCode); + } + const todayLogQuery = ` + SELECT + COUNT(*)::int AS today_executions, + COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures + FROM batch_execution_logs + WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date + ${logWhere} + `; + const prevDayLogQuery = ` + SELECT + COUNT(*)::int AS prev_executions, + COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures + FROM batch_execution_logs + WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day' + ${logWhere} + `; + const [todayResult, prevResult] = await Promise.all([ + query<{ today_executions: number; today_failures: number }>( + todayLogQuery, + logParams + ), + query<{ prev_executions: number; prev_failures: number }>( + prevDayLogQuery, + logParams + ), + ]); + + const config = configResult[0]; + const today = todayResult[0]; + const prev = prevResult[0]; + + return res.json({ + success: true, + data: { + totalBatches: config?.total ?? 0, + activeBatches: config?.active ?? 0, + todayExecutions: today?.today_executions ?? 0, + todayFailures: today?.today_failures ?? 0, + prevDayExecutions: prev?.prev_executions ?? 0, + prevDayFailures: prev?.prev_failures ?? 0, + }, + }); + } catch (error) { + console.error("배치 통계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "배치 통계 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + /** + * 배치별 최근 24시간 스파크라인 (1시간 단위 집계) + * GET /api/batch-management/batch-configs/:id/sparkline + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchSparkline(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode; + const batchId = Number(id); + if (!id || isNaN(batchId)) { + return res.status(400).json({ + success: false, + message: "올바른 배치 ID를 제공해주세요.", + }); + } + + const params: any[] = [batchId]; + let companyFilter = ""; + if (companyCode && companyCode !== "*") { + companyFilter = " AND bel.company_code = $2"; + params.push(companyCode); + } + + // KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장) + const sparklineQuery = ` + WITH kst_slots AS ( + SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour + FROM generate_series( + (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours', + (NOW() AT TIME ZONE 'Asia/Seoul'), + INTERVAL '1 hour' + ) AS s + ), + agg AS ( + SELECT + to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour, + COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success, + COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed + FROM batch_execution_logs bel + WHERE bel.batch_config_id = $1 + AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours' + ${companyFilter} + GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) + ) + SELECT + k.hour, + COALESCE(a.success, 0) AS success, + COALESCE(a.failed, 0) AS failed + FROM kst_slots k + LEFT JOIN agg a ON k.hour = a.hour + ORDER BY k.hour + `; + const data = await query<{ + hour: string; + success: number; + failed: number; + }>(sparklineQuery, params); + + return res.json({ success: true, data }); + } catch (error) { + console.error("스파크라인 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "스파크라인 데이터 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } + + /** + * 배치별 최근 실행 로그 (최대 20건) + * GET /api/batch-management/batch-configs/:id/recent-logs + * 멀티테넌시: company_code 필터링 필수 + */ + static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode; + const batchId = Number(id); + const limit = Math.min(Number(req.query.limit) || 20, 20); + if (!id || isNaN(batchId)) { + return res.status(400).json({ + success: false, + message: "올바른 배치 ID를 제공해주세요.", + }); + } + + let logsQuery: string; + let logsParams: any[]; + if (companyCode === "*") { + logsQuery = ` + SELECT + id, + start_time AS started_at, + end_time AS finished_at, + execution_status AS status, + total_records, + success_records, + failed_records, + error_message, + duration_ms + FROM batch_execution_logs + WHERE batch_config_id = $1 + ORDER BY start_time DESC + LIMIT $2 + `; + logsParams = [batchId, limit]; + } else { + logsQuery = ` + SELECT + id, + start_time AS started_at, + end_time AS finished_at, + execution_status AS status, + total_records, + success_records, + failed_records, + error_message, + duration_ms + FROM batch_execution_logs + WHERE batch_config_id = $1 AND company_code = $2 + ORDER BY start_time DESC + LIMIT $3 + `; + logsParams = [batchId, companyCode, limit]; + } + + const result = await query(logsQuery, logsParams); + return res.json({ success: true, data: result }); + } catch (error) { + console.error("최근 실행 이력 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "최근 실행 이력 조회 실패", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 50ee1ea0..6f57cb12 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -7,6 +7,13 @@ import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +/** + * GET /api/batch-management/stats + * 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수) + * 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록) + */ +router.get("/stats", authenticateToken, BatchManagementController.getBatchStats); + /** * GET /api/batch-management/connections * 사용 가능한 커넥션 목록 조회 @@ -55,6 +62,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat */ router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); +/** + * GET /api/batch-management/batch-configs/:id/sparkline + * 해당 배치 최근 24시간 1시간 단위 실행 집계 + */ +router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline); + +/** + * GET /api/batch-management/batch-configs/:id/recent-logs + * 해당 배치 최근 실행 로그 (최대 20건) + */ +router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs); + /** * PUT /api/batch-management/batch-configs/:id * 배치 설정 업데이트 From 27efe672b924b03734c7efde6b421405a3758055 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:18:02 +0900 Subject: [PATCH 08/23] [agent-pipeline] pipe-20260318044621-56k5 round-6 --- .../automaticMng/batchmngList/create/page.tsx | 102 +++++++++++++-- .../batchmngList/edit/[id]/page.tsx | 122 ++++++++++++++++-- frontend/lib/api/batch.ts | 105 +++++++++++++++ 3 files changed, 307 insertions(+), 22 deletions(-) diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index a4e1095c..2c8feef2 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Badge } from "@/components/ui/badge"; import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -18,6 +19,7 @@ import { ConnectionInfo, ColumnInfo, BatchMappingRequest, + NodeFlowItem, } from "@/lib/api/batch"; export default function BatchCreatePage() { @@ -42,6 +44,11 @@ export default function BatchCreatePage() { // 매핑 상태 const [selectedFromColumn, setSelectedFromColumn] = useState(null); const [mappings, setMappings] = useState([]); + + // 실행 타입: 데이터 매핑 / 노드 플로우 + const [executionType, setExecutionType] = useState<"mapping" | "node_flow">("mapping"); + const [selectedFlowId, setSelectedFlowId] = useState(null); + const [nodeFlows, setNodeFlows] = useState([]); // 로딩 상태 const [loading, setLoading] = useState(false); @@ -52,6 +59,17 @@ export default function BatchCreatePage() { loadConnections(); }, []); + // 노드 플로우 목록 로드 (실행 타입 노드 플로우 시 사용) + useEffect(() => { + if (executionType !== "node_flow") return; + const load = async () => { + const res = await BatchAPI.getNodeFlows(); + if (res.success && res.data) setNodeFlows(res.data); + else setNodeFlows([]); + }; + load(); + }, [executionType]); + const loadConnections = async () => { setLoadingConnections(true); try { @@ -221,19 +239,28 @@ export default function BatchCreatePage() { return; } - if (mappings.length === 0) { - toast.error("최소 하나 이상의 매핑을 추가해주세요."); - return; + if (executionType === "mapping") { + if (mappings.length === 0) { + toast.error("최소 하나 이상의 매핑을 추가해주세요."); + return; + } + } else { + if (selectedFlowId == null) { + toast.error("노드 플로우를 선택해주세요."); + return; + } } setLoading(true); try { - const request = { + const request: BatchMappingRequest = { batchName: batchName, description: description || undefined, cronSchedule: cronSchedule, - mappings: mappings, - isActive: true + mappings: executionType === "mapping" ? mappings : [], + isActive: true, + executionType, + nodeFlowId: executionType === "node_flow" ? selectedFlowId ?? undefined : undefined, }; await BatchAPI.createBatchConfig(request); @@ -305,10 +332,66 @@ export default function BatchCreatePage() { rows={3} /> + + {/* 실행 타입 */} +
+ + { + setExecutionType(v as "mapping" | "node_flow"); + if (v === "mapping") setSelectedFlowId(null); + }} + className="flex flex-col gap-2 sm:flex-row sm:gap-6" + > +
+ + +
+
+ + +
+
+ {executionType === "node_flow" && ( +
+ + + {selectedFlowId != null && nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description && ( +

+ {nodeFlows.find((f) => f.flow_id === selectedFlowId)?.description} +

+ )} +
+ )} +
- {/* 매핑 설정 */} + {/* 매핑 설정 - 데이터 매핑일 때만 표시 */} + {executionType === "mapping" && (
{/* FROM 섹션 */} @@ -474,9 +557,10 @@ export default function BatchCreatePage() {
+ )} {/* 매핑 현황 */} - {mappings.length > 0 && ( + {executionType === "mapping" && mappings.length > 0 && ( 컬럼 매핑 현황 ({mappings.length}개) @@ -529,7 +613,7 @@ export default function BatchCreatePage() { - - - {/* 액션 버튼 영역 */} -
-
- 총{" "} - - {batchConfigs.length.toLocaleString()} - {" "} - 건 -
-
- {/* 배치 목록 */} - {batchConfigs.length === 0 ? ( -
-
- -
-

배치가 없습니다

-

- {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."} -

-
- {!searchTerm && ( - - )} + {/* 통계 카드 4개 */} +
+
+
+

전체 배치

+

+ {statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()} +

+
+ +
+
+
+
+

활성 배치

+

+ {statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()} +

+
+
+ +
+
+
+
+

오늘 실행

+

+ {statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()} +

+
+
+ +
+
+
+
+

오늘 실패

+

+ {statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()} +

+
+
+ +
+
+
+ + {/* 툴바 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-9 rounded-lg border bg-card pl-9 text-xs" + /> +
+
+
+ {(["all", "active", "inactive"] as const).map((s) => ( + + ))} +
+
+ {(["all", "mapping", "restapi", "node_flow"] as const).map((t) => ( + + ))} +
+
+
+ 총 {filteredBatches.length}건 +
+
+ + {/* 배치 테이블 */} + {filteredBatches.length === 0 ? ( +
+ +

+ {searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."} +

+ {!searchTerm && statusFilter === "all" && typeFilter === "all" && ( + + )}
) : ( -
- {batchConfigs.map((batch) => ( - { - toggleBatchStatus(batchId, currentStatus); - }} - onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} - onDelete={deleteBatch} - getMappingSummary={getMappingSummary} - /> - ))} -
- )} - - {/* 페이지네이션 */} - {totalPages > 1 && ( -
- - -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const pageNum = i + 1; - return ( - - ); - })} -
- - +
+ + + + + + + + + + + + + {filteredBatches.map((batch) => ( + setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))} + executingBatch={executingBatch} + onExecute={executeBatch} + onToggleStatus={toggleBatchStatus} + onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)} + onDelete={deleteBatch} + cronToKorean={cronToKorean} + /> + ))} + +
+ + 배치 + + 타입 + + 스케줄 + + 최근 24h + + 마지막 실행 + + 액션 +
)} @@ -311,60 +386,49 @@ export default function BatchManagementPage() { {isBatchTypeModalOpen && (
-
-

배치 타입 선택

- -
- {/* DB → DB */} - - - {/* REST API → DB */} - -
- -
- -
+

배치 타입 선택

+
+ + +
+
+
)}
- - {/* Scroll to Top 버튼 */}
); -} \ No newline at end of file +} diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index 374c81a2..13cf9927 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -1,173 +1,377 @@ "use client"; -import React from "react"; -import { Card, CardContent } from "@/components/ui/card"; +import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Play, - Pause, - Edit, - Trash2, - RefreshCw, - Clock, - Database, - Calendar, - Activity, - Settings -} from "lucide-react"; +import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react"; import { BatchConfig } from "@/lib/api/batch"; +import apiClient from "@/lib/api/client"; +import { cn } from "@/lib/utils"; + +interface SparklineSlot { + hour: string; + success: number; + failed: number; +} + +interface BatchRecentLog { + id?: number; + started_at?: string; + finished_at?: string; + status?: string; + total_records?: number; + success_records?: number; + failed_records?: number; + error_message?: string | null; + duration_ms?: number; +} + +type LedStatus = "on" | "run" | "off" | "err"; + +function BatchLED({ status }: { status: LedStatus }) { + return ( +
+ ); +} + +/** 스파크라인 24바 (div 기반, 높이 24px) */ +function SparklineBars({ slots }: { slots: SparklineSlot[] }) { + if (!slots || slots.length === 0) { + return ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+ ); + } + return ( +
+ {slots.slice(0, 24).map((slot, i) => { + const hasRun = slot.success + slot.failed > 0; + const isFail = slot.failed > 0; + const height = + !hasRun ? 5 : isFail ? Math.min(40, 20 + slot.failed * 5) : Math.max(80, Math.min(95, 80 + slot.success)); + return ( +
+ ); + })} +
+ ); +} interface BatchCardProps { batch: BatchConfig; + expanded: boolean; + onToggleExpand: () => void; executingBatch: number | null; onExecute: (batchId: number) => void; onToggleStatus: (batchId: number, currentStatus: string) => void; onEdit: (batchId: number) => void; onDelete: (batchId: number, batchName: string) => void; - getMappingSummary: (mappings: any[]) => string; + cronToKorean: (cron: string) => string; } export default function BatchCard({ batch, + expanded, + onToggleExpand, executingBatch, onExecute, onToggleStatus, onEdit, onDelete, - getMappingSummary + cronToKorean, }: BatchCardProps) { - // 상태에 따른 스타일 결정 + const [sparkline, setSparkline] = useState([]); + const [recentLogs, setRecentLogs] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + const isExecuting = executingBatch === batch.id; - const isActive = batch.is_active === 'Y'; + const isActive = batch.is_active === "Y"; + const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off"; + + const executionType = (batch as { execution_type?: string }).execution_type; + const typeLabel = + executionType === "node_flow" + ? "노드 플로우" + : executionType === "restapi" || (batch.batch_mappings?.some((m) => m.from_connection_type === "external")) + ? "API→DB" + : "DB→DB"; + const typeBadgeClass = + executionType === "node_flow" + ? "bg-indigo-500/10 text-indigo-600" + : executionType === "restapi" || batch.batch_mappings?.some((m) => m.from_connection_type === "external") + ? "bg-violet-500/10 text-violet-600" + : "bg-cyan-500/10 text-cyan-600"; + + const loadDetail = useCallback(async () => { + if (!batch.id) return; + setDetailLoading(true); + try { + const [sparkRes, logsRes] = await Promise.all([ + apiClient.get<{ success: boolean; data?: SparklineSlot[] }>( + `/batch-management/batch-configs/${batch.id}/sparkline` + ), + apiClient.get<{ success: boolean; data?: BatchRecentLog[] }>( + `/batch-management/batch-configs/${batch.id}/recent-logs?limit=5` + ), + ]); + if (sparkRes.data?.success && Array.isArray(sparkRes.data.data)) { + setSparkline(sparkRes.data.data); + } + if (logsRes.data?.success && Array.isArray(logsRes.data.data)) { + setRecentLogs(logsRes.data.data); + } + } catch { + setSparkline([]); + setRecentLogs([]); + } finally { + setDetailLoading(false); + } + }, [batch.id]); + + useEffect(() => { + if (expanded) loadDetail(); + }, [expanded, loadDetail]); + + const lastLog = recentLogs[0]; + const lastRunText = isExecuting + ? "실행 중..." + : !isActive + ? "-" + : lastLog?.started_at + ? new Date(lastLog.started_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + : "-"; + const lastRunSub = + !isActive || !lastLog?.started_at + ? "비활성" + : isExecuting + ? "" + : (() => { + const min = Math.floor((Date.now() - new Date(lastLog.started_at).getTime()) / 60000); + if (min < 1) return "방금 전"; + if (min < 60) return `${min}분 전`; + return `${Math.floor(min / 60)}시간 전`; + })(); + + const handleRowClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + onToggleExpand(); + }; return ( - - - {/* 헤더 */} -
-
-
- -

{batch.batch_name}

-
-

- {batch.description || '설명 없음'} + <> + (e.key === "Enter" || e.key === " ") && onToggleExpand()} + className={cn( + "min-h-[60px] border-b transition-colors hover:bg-card/80", + expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]" + )} + > + +

+ {expanded ? ( + + ) : ( + + )} + +
+ + +
+

+ {batch.batch_name}

+

{batch.description || "설명 없음"}

- - {isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} - -
- - {/* 정보 */} -
- {/* 스케줄 정보 */} -
- - - 스케줄 - - {batch.cron_schedule} -
- - {/* 생성일 정보 */} -
- - - 생성일 - - - {new Date(batch.created_date).toLocaleDateString('ko-KR')} - -
- - {/* 매핑 정보 */} - {batch.batch_mappings && batch.batch_mappings.length > 0 && ( -
- - - 매핑 - - - {batch.batch_mappings.length}개 - + + + + {typeLabel} + + + +

+ {batch.cron_schedule} +

+

{cronToKorean(batch.cron_schedule)}

+ + + {expanded ? ( + + ) : ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))}
)} -
- - {/* 실행 중 프로그레스 */} - {isExecuting && ( -
-
- - 실행 중... -
-
-
-
+ + +

{lastRunText}

+

{lastRunSub}

+ + +
e.stopPropagation()}> + + +
- )} - - {/* 액션 버튼 */} -
- {/* 실행 버튼 */} - - - {/* 활성화/비활성화 버튼 */} - - - {/* 수정 버튼 */} - - - {/* 삭제 버튼 */} - -
- - + + + {expanded && ( + + +
+ {detailLoading ? ( +
로딩 중...
+ ) : ( +
+
+

+ {batch.description || batch.batch_name} 배치입니다. 스케줄: {cronToKorean(batch.cron_schedule)} + {recentLogs.length > 0 && + ` · 최근 실행 ${recentLogs.length}건`} +

+
+
+
+

+ + 최근 24시간 +

+
+ +
+
+
+

+ + 실행 이력 (최근 5건) +

+
+ + + + + + + + + + + {recentLogs.length === 0 ? ( + + + + ) : ( + recentLogs.map((log, i) => ( + + + + + + + )) + )} + +
시간상태처리에러
+ 이력 없음 +
+ {log.started_at + ? new Date(log.started_at).toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"} + + + {log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"} + + + {log.success_records ?? log.total_records ?? 0}건 + {log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""} + + {log.error_message || "-"} +
+
+
+
+
+ )} +
+ + + )} + ); } From cd0f0df34dd498daad1a87e885ef72233a9a155f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:37:57 +0900 Subject: [PATCH 12/23] [agent-pipeline] rollback to 7f33b3fd --- .../admin/automaticMng/batchmngList/page.tsx | 598 ++++++++---------- frontend/components/admin/BatchCard.tsx | 486 +++++--------- 2 files changed, 408 insertions(+), 676 deletions(-) diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index 64e725e8..a384b645 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -1,102 +1,57 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Plus, - Search, +import { + Plus, + Search, RefreshCw, - Database, - LayoutGrid, - CheckCircle, - Activity, - AlertTriangle, + Database } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; -import { BatchAPI, type BatchConfig } from "@/lib/api/batch"; -import apiClient from "@/lib/api/client"; -import { ScrollToTop } from "@/components/common/ScrollToTop"; -import { cn } from "@/lib/utils"; +import { + BatchAPI, + BatchConfig, + BatchMapping, +} from "@/lib/api/batch"; import BatchCard from "@/components/admin/BatchCard"; - -// 대시보드 통계 타입 (백엔드 GET /batch-management/stats) -interface BatchStats { - totalBatches: number; - activeBatches: number; - todayExecutions: number; - todayFailures: number; - prevDayExecutions?: number; - prevDayFailures?: number; -} - -// 스파크라인/최근 로그 타입은 BatchCard 내부 정의 사용 - -/** Cron 표현식 → 한글 설명 */ -function cronToKorean(cron: string): string { - if (!cron || !cron.trim()) return "-"; - const parts = cron.trim().split(/\s+/); - if (parts.length < 5) return cron; - const [min, hour, dayOfMonth, month, dayOfWeek] = parts; - - // 매 30분: */30 * * * * - if (min.startsWith("*/") && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - const n = min.slice(2); - return `매 ${n}분`; - } - // 매 N시간: 0 */2 * * * - if (min === "0" && hour.startsWith("*/") && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - const n = hour.slice(2); - return `매 ${n}시간`; - } - // 매일 HH:MM: 0 1 * * * 또는 0 6,18 * * * - if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { - if (hour.includes(",")) { - const times = hour.split(",").map((h) => `${h.padStart(2, "0")}:00`); - return times.join(", "); - } - return `매일 ${hour.padStart(2, "0")}:00`; - } - // 매주 일 03:00: 0 3 * * 0 - if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek !== "*" && dayOfWeek !== "?") { - const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; - const d = dayNames[parseInt(dayOfWeek, 10)] ?? dayOfWeek; - return `매주 ${d} ${hour.padStart(2, "0")}:00`; - } - // 매월 1일 00:00: 0 0 1 * * - if (min === "0" && hour === "0" && dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") { - return `매월 ${dayOfMonth}일 00:00`; - } - return cron; -} - -type StatusFilter = "all" | "active" | "inactive"; -type TypeFilter = "all" | "mapping" | "restapi" | "node_flow"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; export default function BatchManagementPage() { const router = useRouter(); + + // 상태 관리 const [batchConfigs, setBatchConfigs] = useState([]); const [loading, setLoading] = useState(false); - const [stats, setStats] = useState(null); - const [statsLoading, setStatsLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); const [executingBatch, setExecutingBatch] = useState(null); - const [expandedId, setExpandedId] = useState(null); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); - const loadBatchConfigs = useCallback(async () => { + // 페이지 로드 시 배치 목록 조회 + useEffect(() => { + loadBatchConfigs(); + }, [currentPage, searchTerm]); + + // 배치 설정 목록 조회 + const loadBatchConfigs = async () => { setLoading(true); try { const response = await BatchAPI.getBatchConfigs({ - limit: 500, + page: currentPage, + limit: 10, search: searchTerm || undefined, }); + if (response.success && response.data) { setBatchConfigs(response.data); + if (response.pagination) { + setTotalPages(response.pagination.totalPages); + } } else { setBatchConfigs([]); } @@ -107,46 +62,20 @@ export default function BatchManagementPage() { } finally { setLoading(false); } - }, [searchTerm]); - - const loadStats = useCallback(async () => { - setStatsLoading(true); - try { - const res = await apiClient.get<{ success: boolean; data?: BatchStats }>("/batch-management/stats"); - if (res.data?.success && res.data.data) { - setStats(res.data.data); - } else { - setStats(null); - } - } catch { - setStats(null); - } finally { - setStatsLoading(false); - } - }, []); - - useEffect(() => { - loadBatchConfigs(); - }, [loadBatchConfigs]); - - useEffect(() => { - loadStats(); - }, [loadStats]); + }; + // 배치 수동 실행 const executeBatch = async (batchId: number) => { setExecutingBatch(batchId); try { const response = await BatchAPI.executeBatchConfig(batchId); if (response.success) { - toast.success( - `배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)` - ); - loadBatchConfigs(); - loadStats(); + toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`); } else { toast.error("배치 실행에 실패했습니다."); } } catch (error) { + console.error("배치 실행 실패:", error); showErrorToast("배치 실행에 실패했습니다", error, { guidance: "배치 설정을 확인하고 다시 시도해 주세요.", }); @@ -155,230 +84,226 @@ export default function BatchManagementPage() { } }; + // 배치 활성화/비활성화 토글 const toggleBatchStatus = async (batchId: number, currentStatus: string) => { + console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus }); + try { - const newStatus = currentStatus === "Y" ? "N" : "Y"; - await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" }); - toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`); - loadBatchConfigs(); - loadStats(); + const newStatus = currentStatus === 'Y' ? 'N' : 'Y'; + console.log("📝 새로운 상태:", newStatus); + + const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); + console.log("✅ API 호출 성공:", result); + + toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`); + loadBatchConfigs(); // 목록 새로고침 } catch (error) { - console.error("배치 상태 변경 실패:", error); + console.error("❌ 배치 상태 변경 실패:", error); toast.error("배치 상태 변경에 실패했습니다."); } }; + // 배치 삭제 const deleteBatch = async (batchId: number, batchName: string) => { - if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return; + if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) { + return; + } + try { await BatchAPI.deleteBatchConfig(batchId); toast.success("배치가 삭제되었습니다."); - loadBatchConfigs(); - loadStats(); + loadBatchConfigs(); // 목록 새로고침 } catch (error) { console.error("배치 삭제 실패:", error); toast.error("배치 삭제에 실패했습니다."); } }; - const handleCreateBatch = () => setIsBatchTypeModalOpen(true); - - const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => { - setIsBatchTypeModalOpen(false); - if (type === "db-to-db") { - router.push("/admin/batchmng/create"); - } else { - router.push("/admin/batch-management-new"); - } + // 검색 처리 + const handleSearch = (value: string) => { + setSearchTerm(value); + setCurrentPage(1); // 검색 시 첫 페이지로 이동 }; - const filteredBatches = useMemo(() => { - let list = batchConfigs; - if (statusFilter === "active") list = list.filter((b) => b.is_active === "Y"); - else if (statusFilter === "inactive") list = list.filter((b) => b.is_active !== "Y"); - const et = (b: BatchConfig) => (b as { execution_type?: string }).execution_type; - if (typeFilter === "mapping") list = list.filter((b) => !et(b) || et(b) === "mapping"); - else if (typeFilter === "restapi") list = list.filter((b) => et(b) === "restapi"); - else if (typeFilter === "node_flow") list = list.filter((b) => et(b) === "node_flow"); - return list; - }, [batchConfigs, statusFilter, typeFilter]); + // 매핑 정보 요약 생성 + const getMappingSummary = (mappings: BatchMapping[]) => { + if (!mappings || mappings.length === 0) { + return "매핑 없음"; + } + + const tableGroups = new Map(); + mappings.forEach(mapping => { + const key = `${mapping.from_table_name} → ${mapping.to_table_name}`; + tableGroups.set(key, (tableGroups.get(key) || 0) + 1); + }); + + const summaries = Array.from(tableGroups.entries()).map(([key, count]) => + `${key} (${count}개 컬럼)` + ); + + return summaries.join(", "); + }; + + // 배치 추가 버튼 클릭 핸들러 + const handleCreateBatch = () => { + setIsBatchTypeModalOpen(true); + }; + + // 배치 타입 선택 핸들러 + const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => { + console.log("배치 타입 선택:", type); + setIsBatchTypeModalOpen(false); + + if (type === 'db-to-db') { + // 기존 DB → DB 배치 생성 페이지로 이동 + console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create'); + router.push('/admin/batchmng/create'); + } else if (type === 'restapi-to-db') { + // 새로운 REST API 배치 페이지로 이동 + console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); + try { + router.push('/admin/batch-management-new'); + console.log("라우터 push 실행 완료"); + } catch (error) { + console.error("라우터 push 오류:", error); + // 대안: window.location 사용 + window.location.href = '/admin/batch-management-new'; + } + } + }; return (
- {/* 헤더 */} -
-
-

배치 관리

-

- 데이터 동기화 배치 작업을 모니터링하고 관리합니다. -

-
-
- -
+ + {/* 액션 버튼 영역 */} +
+
+ 총{" "} + + {batchConfigs.length.toLocaleString()} + {" "} + 건 +
+
- {/* 통계 카드 4개 */} -
-
-
-

전체 배치

-

- {statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

활성 배치

-

- {statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

오늘 실행

-

- {statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()} -

-
-
- -
-
-
-
-

오늘 실패

-

- {statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()} -

-
-
- -
-
-
- - {/* 툴바 */} -
-
- - setSearchTerm(e.target.value)} - className="h-9 rounded-lg border bg-card pl-9 text-xs" - /> -
-
-
- {(["all", "active", "inactive"] as const).map((s) => ( - - ))} + + 첫 번째 배치 추가 + + )}
-
- {(["all", "mapping", "restapi", "node_flow"] as const).map((t) => ( - - ))} -
-
-
- 총 {filteredBatches.length}건 -
-
- - {/* 배치 테이블 */} - {filteredBatches.length === 0 ? ( -
- -

- {searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."} -

- {!searchTerm && statusFilter === "all" && typeFilter === "all" && ( - - )}
) : ( -
- - - - - - - - - - - - - {filteredBatches.map((batch) => ( - setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))} - executingBatch={executingBatch} - onExecute={executeBatch} - onToggleStatus={toggleBatchStatus} - onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)} - onDelete={deleteBatch} - cronToKorean={cronToKorean} - /> - ))} - -
- - 배치 - - 타입 - - 스케줄 - - 최근 24h - - 마지막 실행 - - 액션 -
+
+ {batchConfigs.map((batch) => ( + { + toggleBatchStatus(batchId, currentStatus); + }} + onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} + onDelete={deleteBatch} + getMappingSummary={getMappingSummary} + /> + ))} +
+ )} + + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const pageNum = i + 1; + return ( + + ); + })} +
+ +
)} @@ -386,49 +311,60 @@ export default function BatchManagementPage() { {isBatchTypeModalOpen && (
-

배치 타입 선택

-
- - -
-
- +
+

배치 타입 선택

+ +
+ {/* DB → DB */} + + + {/* REST API → DB */} + +
+ +
+ +
)}
+ + {/* Scroll to Top 버튼 */}
); -} +} \ No newline at end of file diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index 13cf9927..374c81a2 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -1,377 +1,173 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Play, + Pause, + Edit, + Trash2, + RefreshCw, + Clock, + Database, + Calendar, + Activity, + Settings +} from "lucide-react"; import { BatchConfig } from "@/lib/api/batch"; -import apiClient from "@/lib/api/client"; -import { cn } from "@/lib/utils"; - -interface SparklineSlot { - hour: string; - success: number; - failed: number; -} - -interface BatchRecentLog { - id?: number; - started_at?: string; - finished_at?: string; - status?: string; - total_records?: number; - success_records?: number; - failed_records?: number; - error_message?: string | null; - duration_ms?: number; -} - -type LedStatus = "on" | "run" | "off" | "err"; - -function BatchLED({ status }: { status: LedStatus }) { - return ( -
- ); -} - -/** 스파크라인 24바 (div 기반, 높이 24px) */ -function SparklineBars({ slots }: { slots: SparklineSlot[] }) { - if (!slots || slots.length === 0) { - return ( -
- {Array.from({ length: 24 }).map((_, i) => ( -
- ))} -
- ); - } - return ( -
- {slots.slice(0, 24).map((slot, i) => { - const hasRun = slot.success + slot.failed > 0; - const isFail = slot.failed > 0; - const height = - !hasRun ? 5 : isFail ? Math.min(40, 20 + slot.failed * 5) : Math.max(80, Math.min(95, 80 + slot.success)); - return ( -
- ); - })} -
- ); -} interface BatchCardProps { batch: BatchConfig; - expanded: boolean; - onToggleExpand: () => void; executingBatch: number | null; onExecute: (batchId: number) => void; onToggleStatus: (batchId: number, currentStatus: string) => void; onEdit: (batchId: number) => void; onDelete: (batchId: number, batchName: string) => void; - cronToKorean: (cron: string) => string; + getMappingSummary: (mappings: any[]) => string; } export default function BatchCard({ batch, - expanded, - onToggleExpand, executingBatch, onExecute, onToggleStatus, onEdit, onDelete, - cronToKorean, + getMappingSummary }: BatchCardProps) { - const [sparkline, setSparkline] = useState([]); - const [recentLogs, setRecentLogs] = useState([]); - const [detailLoading, setDetailLoading] = useState(false); - + // 상태에 따른 스타일 결정 const isExecuting = executingBatch === batch.id; - const isActive = batch.is_active === "Y"; - const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off"; - - const executionType = (batch as { execution_type?: string }).execution_type; - const typeLabel = - executionType === "node_flow" - ? "노드 플로우" - : executionType === "restapi" || (batch.batch_mappings?.some((m) => m.from_connection_type === "external")) - ? "API→DB" - : "DB→DB"; - const typeBadgeClass = - executionType === "node_flow" - ? "bg-indigo-500/10 text-indigo-600" - : executionType === "restapi" || batch.batch_mappings?.some((m) => m.from_connection_type === "external") - ? "bg-violet-500/10 text-violet-600" - : "bg-cyan-500/10 text-cyan-600"; - - const loadDetail = useCallback(async () => { - if (!batch.id) return; - setDetailLoading(true); - try { - const [sparkRes, logsRes] = await Promise.all([ - apiClient.get<{ success: boolean; data?: SparklineSlot[] }>( - `/batch-management/batch-configs/${batch.id}/sparkline` - ), - apiClient.get<{ success: boolean; data?: BatchRecentLog[] }>( - `/batch-management/batch-configs/${batch.id}/recent-logs?limit=5` - ), - ]); - if (sparkRes.data?.success && Array.isArray(sparkRes.data.data)) { - setSparkline(sparkRes.data.data); - } - if (logsRes.data?.success && Array.isArray(logsRes.data.data)) { - setRecentLogs(logsRes.data.data); - } - } catch { - setSparkline([]); - setRecentLogs([]); - } finally { - setDetailLoading(false); - } - }, [batch.id]); - - useEffect(() => { - if (expanded) loadDetail(); - }, [expanded, loadDetail]); - - const lastLog = recentLogs[0]; - const lastRunText = isExecuting - ? "실행 중..." - : !isActive - ? "-" - : lastLog?.started_at - ? new Date(lastLog.started_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) - : "-"; - const lastRunSub = - !isActive || !lastLog?.started_at - ? "비활성" - : isExecuting - ? "" - : (() => { - const min = Math.floor((Date.now() - new Date(lastLog.started_at).getTime()) / 60000); - if (min < 1) return "방금 전"; - if (min < 60) return `${min}분 전`; - return `${Math.floor(min / 60)}시간 전`; - })(); - - const handleRowClick = (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("button")) return; - onToggleExpand(); - }; + const isActive = batch.is_active === 'Y'; return ( - <> - (e.key === "Enter" || e.key === " ") && onToggleExpand()} - className={cn( - "min-h-[60px] border-b transition-colors hover:bg-card/80", - expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]" - )} - > - -
- {expanded ? ( - - ) : ( - - )} - -
- - -
-

- {batch.batch_name} + + + {/* 헤더 */} +

+
+
+ +

{batch.batch_name}

+
+

+ {batch.description || '설명 없음'}

-

{batch.description || "설명 없음"}

- - - - {typeLabel} - - - -

- {batch.cron_schedule} -

-

{cronToKorean(batch.cron_schedule)}

- - - {expanded ? ( - - ) : ( -
- {Array.from({ length: 24 }).map((_, i) => ( -
- ))} + + {isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} + +
+ + {/* 정보 */} +
+ {/* 스케줄 정보 */} +
+ + + 스케줄 + + {batch.cron_schedule} +
+ + {/* 생성일 정보 */} +
+ + + 생성일 + + + {new Date(batch.created_date).toLocaleDateString('ko-KR')} + +
+ + {/* 매핑 정보 */} + {batch.batch_mappings && batch.batch_mappings.length > 0 && ( +
+ + + 매핑 + + + {batch.batch_mappings.length}개 +
)} - - -

{lastRunText}

-

{lastRunSub}

- - -
e.stopPropagation()}> - - - -
- - - {expanded && ( - - -
- {detailLoading ? ( -
로딩 중...
- ) : ( -
-
-

- {batch.description || batch.batch_name} 배치입니다. 스케줄: {cronToKorean(batch.cron_schedule)} - {recentLogs.length > 0 && - ` · 최근 실행 ${recentLogs.length}건`} -

-
-
-
-

- - 최근 24시간 -

-
- -
-
-
-

- - 실행 이력 (최근 5건) -

-
- - - - - - - - - - - {recentLogs.length === 0 ? ( - - - - ) : ( - recentLogs.map((log, i) => ( - - - - - - - )) - )} - -
시간상태처리에러
- 이력 없음 -
- {log.started_at - ? new Date(log.started_at).toLocaleString("ko-KR", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : "-"} - - - {log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"} - - - {log.success_records ?? log.total_records ?? 0}건 - {log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""} - - {log.error_message || "-"} -
-
-
-
-
- )} +
+ + {/* 실행 중 프로그레스 */} + {isExecuting && ( +
+
+ + 실행 중...
- - - )} - +
+
+
+
+ )} + + {/* 액션 버튼 */} +
+ {/* 실행 버튼 */} + + + {/* 활성화/비활성화 버튼 */} + + + {/* 수정 버튼 */} + + + {/* 삭제 버튼 */} + +
+ + ); } From d8b56a1a782d9e561459089a0c44d32a12261733 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:48:13 +0900 Subject: [PATCH 13/23] [agent-pipeline] pipe-20260318044621-56k5 round-9 --- .../app/(main)/admin/automaticMng/batchmngList/page.tsx | 9 +++------ frontend/components/admin/BatchCard.tsx | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index a384b645..e8ce9a78 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -12,13 +12,10 @@ import { import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; -import { - BatchAPI, - BatchConfig, - BatchMapping, -} from "@/lib/api/batch"; -import BatchCard from "@/components/admin/BatchCard"; +import { BatchAPI, type BatchConfig, type BatchMapping } from "@/lib/api/batch"; +import { apiClient } from "@/lib/api/client"; import { ScrollToTop } from "@/components/common/ScrollToTop"; +import BatchCard from "@/components/admin/BatchCard"; export default function BatchManagementPage() { const router = useRouter(); diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index 374c81a2..faa935aa 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -16,6 +16,7 @@ import { Activity, Settings } from "lucide-react"; +import { apiClient } from "@/lib/api/client"; import { BatchConfig } from "@/lib/api/batch"; interface BatchCardProps { From 7f781b01776f04dc964107e192f1c0836ff93508 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 14:56:46 +0900 Subject: [PATCH 14/23] [agent-pipeline] pipe-20260318044621-56k5 round-10 --- .../(main)/admin/automaticMng/batchmngList/page.tsx | 2 +- frontend/components/admin/BatchCard.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index e8ce9a78..b1461416 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -89,7 +89,7 @@ export default function BatchManagementPage() { const newStatus = currentStatus === 'Y' ? 'N' : 'Y'; console.log("📝 새로운 상태:", newStatus); - const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); + const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === 'Y' }); console.log("✅ API 호출 성공:", result); toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`); diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index faa935aa..9e869b16 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -79,7 +79,7 @@ export default function BatchCard({ 생성일 - {new Date(batch.created_date).toLocaleDateString('ko-KR')} + {batch.created_date ? new Date(batch.created_date).toLocaleDateString('ko-KR') : '-'}
@@ -119,7 +119,7 @@ export default function BatchCard({ +
+ +
-

배치관리 매핑 시스템

-

새로운 배치 매핑을 생성합니다.

+

새 배치 등록

+

데이터를 자동으로 처리하는 배치를 만들어 보세요

+ +
+
+ + {/* 실행 방식 선택 */} +
+

어떤 방식으로 실행할까요?

+
+ +
{/* 기본 정보 */} - - - 기본 정보 - - -
-
- - setBatchName(e.target.value)} - placeholder="배치명을 입력하세요" - /> -
-
- - setCronSchedule(e.target.value)} - placeholder="0 12 * * * (매일 12시)" - /> -
+
+

기본 정보

+
+
+ + setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" /> +

어떤 작업인지 한눈에 알 수 있게 적어주세요

-
- -