diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..e2fa0458 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,857 @@ +# Cursor Rules for ERP-node Project + +## shadcn/ui 웹 스타일 가이드라인 + +모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다. + +### 1. Color System (색상 시스템) + +#### CSS Variables 사용 +shadcn은 CSS Variables를 사용하여 테마를 관리하며, 모든 색상은 HSL 형식으로 정의됩니다. + +**기본 색상 토큰 (항상 사용):** +- `--background`: 페이지 배경 +- `--foreground`: 기본 텍스트 +- `--primary`: 메인 액션 (Indigo 계열) +- `--primary-foreground`: Primary 위 텍스트 +- `--secondary`: 보조 액션 +- `--muted`: 약한 배경 +- `--muted-foreground`: 보조 텍스트 +- `--destructive`: 삭제/에러 (Rose 계열) +- `--border`: 테두리 +- `--ring`: 포커스 링 + +**Tailwind 클래스로 사용:** +```tsx +bg-primary text-primary-foreground +bg-secondary text-secondary-foreground +bg-muted text-muted-foreground +bg-destructive text-destructive-foreground +border-border +``` + +**추가 시맨틱 컬러:** +- Success: `--success` (Emerald-600 계열) +- Warning: `--warning` (Amber-500 계열) +- Info: `--info` (Cyan-500 계열) + +### 2. Spacing System (간격) + +**Tailwind Scale (4px 기준):** +- 0.5 = 2px, 1 = 4px, 2 = 8px, 3 = 12px, 4 = 16px, 6 = 24px, 8 = 32px + +**컴포넌트별 권장 간격:** +- 카드 패딩: `p-6` (24px) +- 카드 간 마진: `gap-6` (24px) +- 폼 필드 간격: `space-y-4` (16px) +- 버튼 내부 패딩: `px-4 py-2` +- 섹션 간격: `space-y-8` 또는 `space-y-12` + +### 3. Typography (타이포그래피) + +**용도별 타이포그래피 (필수):** +- 페이지 제목: `text-3xl font-bold` +- 섹션 제목: `text-2xl font-semibold` +- 카드 제목: `text-xl font-semibold` +- 서브 제목: `text-lg font-medium` +- 본문 텍스트: `text-sm text-muted-foreground` +- 작은 텍스트: `text-xs text-muted-foreground` +- 버튼 텍스트: `text-sm font-medium` +- 폼 라벨: `text-sm font-medium` + +### 4. Button Variants (버튼 스타일) + +**필수 사용 패턴:** +```tsx +// Primary (기본) + + +// Secondary + + +// Outline + + +// Ghost + + +// Destructive + +``` + +**버튼 크기:** +- `size="sm"`: 작은 버튼 (h-9 px-3) +- `size="default"`: 기본 버튼 (h-10 px-4 py-2) +- `size="lg"`: 큰 버튼 (h-11 px-8) +- `size="icon"`: 아이콘 전용 (h-10 w-10) + +### 5. Input States (입력 필드 상태) + +**필수 적용 상태:** +```tsx +// Default +className="border-input" + +// Focus (모든 입력 필드 필수) +className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + +// Error +className="border-destructive focus-visible:ring-destructive" + +// Disabled +className="disabled:opacity-50 disabled:cursor-not-allowed" +``` + +### 6. Card Structure (카드 구조) + +**표준 카드 구조 (필수):** +```tsx + + + 제목 + 설명 + + + {/* 내용 */} + + + {/* 액션 버튼들 */} + + +``` + +### 7. Border & Radius (테두리 및 둥근 모서리) + +**컴포넌트별 권장:** +- 버튼: `rounded-md` (6px) +- 입력 필드: `rounded-md` (6px) +- 카드: `rounded-lg` (8px) +- 배지: `rounded-full` +- 모달/대화상자: `rounded-lg` (8px) +- 드롭다운: `rounded-md` (6px) + +### 8. Shadow (그림자) + +**용도별 권장:** +- 카드: `shadow-sm` +- 드롭다운: `shadow-md` +- 모달: `shadow-lg` +- 팝오버: `shadow-md` +- 버튼 호버: `shadow-sm` + +### 9. Interactive States (상호작용 상태) + +**필수 적용 패턴:** +```tsx +// Hover +hover:bg-primary/90 // 버튼 +hover:bg-accent // Ghost 버튼 +hover:underline // 링크 +hover:shadow-md transition-shadow // 카드 + +// Focus (모든 인터랙티브 요소 필수) +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + +// Active +active:scale-95 transition-transform // 버튼 + +// Disabled +disabled:opacity-50 disabled:cursor-not-allowed +``` + +### 10. Animation (애니메이션) + +**권장 Duration:** +- 빠른 피드백: `duration-75` +- 기본: `duration-150` +- 부드러운 전환: `duration-300` + +**권장 패턴:** +- 버튼 클릭: `transition-transform duration-150 active:scale-95` +- 색상 전환: `transition-colors duration-150` +- 드롭다운 열기: `transition-all duration-200` + +### 11. Responsive (반응형) + +**Breakpoints:** +- `sm`: 640px (모바일 가로) +- `md`: 768px (태블릿) +- `lg`: 1024px (노트북) +- `xl`: 1280px (데스크톱) + +**반응형 패턴:** +```tsx +// 모바일 우선 접근 +className="flex-col md:flex-row" +className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3" +className="text-2xl md:text-3xl lg:text-4xl" +className="p-4 md:p-6 lg:p-8" +``` + +### 12. Accessibility (접근성) + +**필수 적용 사항:** +1. 포커스 표시: 모든 인터랙티브 요소에 `focus-visible:ring-2` 적용 +2. ARIA 레이블: 적절한 `aria-label`, `aria-describedby` 사용 +3. 키보드 네비게이션: Tab, Enter, Space, Esc 지원 +4. 색상 대비: 최소 4.5:1 (일반 텍스트), 3:1 (큰 텍스트) + +### 13. Class 순서 (일관성 유지) + +**항상 이 순서로 작성:** +1. Layout: `flex`, `grid`, `block` +2. Sizing: `w-full`, `h-10` +3. Spacing: `p-4`, `m-2`, `gap-4` +4. Typography: `text-sm`, `font-medium` +5. Colors: `bg-primary`, `text-white` +6. Border: `border`, `rounded-md` +7. Effects: `shadow-sm`, `opacity-50` +8. States: `hover:`, `focus:`, `disabled:` +9. Responsive: `md:`, `lg:` + +### 14. 실무 적용 규칙 + +1. **shadcn 컴포넌트 우선 사용**: 커스텀 스타일보다 shadcn 기본 컴포넌트 활용 +2. **cn 유틸리티 사용**: 조건부 클래스는 `cn()` 함수로 결합 +3. **테마 변수 사용**: 하드코딩된 색상 대신 CSS 변수 사용 +4. **다크모드 고려**: 모든 컴포넌트는 다크모드 호환 필수 +5. **일관성 유지**: 같은 용도의 컴포넌트는 같은 스타일 사용 + +### 15. 금지 사항 + +1. ❌ 하드코딩된 색상 값 사용 (예: `bg-blue-500` 대신 `bg-primary`) +2. ❌ 인라인 스타일로 색상 지정 (예: `style={{ color: '#3b82f6' }}`) +3. ❌ 포커스 스타일 제거 (`outline-none`만 단독 사용) +4. ❌ 접근성 무시 (ARIA 레이블 누락) +5. ❌ 반응형 무시 (데스크톱 전용 스타일) +6. ❌ **중첩 박스 금지**: 사용자가 명시적으로 요청하지 않는 한 Card 안에 Card, Border 안에 Border 같은 중첩된 컨테이너 구조를 만들지 않음 + +### 16. 중첩 박스 금지 상세 규칙 + +**금지되는 패턴 (사용자 요청 없이):** +```tsx +// ❌ Card 안에 Card + + + // 중첩 금지! + 내용 + + + + +// ❌ Border 안에 Border +
+
// 중첩 금지! + 내용 +
+
+ +// ❌ 불필요한 래퍼 +
+
// 중첩 금지! + 내용 +
+
+``` + +**허용되는 패턴:** +```tsx +// ✅ 단일 Card + + + 제목 + + + 내용 + + + +// ✅ 의미적으로 다른 컴포넌트 조합 + + + // Dialog는 별도 UI 레이어 + ... + + + + +// ✅ 그리드/리스트 내부의 Card들 +
+ 항목 1 + 항목 2 + 항목 3 +
+``` + +**예외 상황 (사용자가 명시적으로 요청한 경우만):** +- 대시보드에서 섹션별 그룹핑이 필요한 경우 +- 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우 +- 드래그앤드롭 등 특수 기능을 위한 경우 + +**원칙:** +- 심플하고 깔끔한 디자인 유지 +- 불필요한 시각적 레이어 제거 +- 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지 + +### 17. 표준 모달(Dialog) 디자인 패턴 + +**프로젝트 표준 모달 구조 (플로우 관리 기준):** + +```tsx + + + {/* 헤더: 제목 + 설명 */} + + 모달 제목 + + 모달에 대한 간단한 설명 + + + + {/* 컨텐츠: 폼 필드들 */} +
+ {/* 각 입력 필드 */} +
+ + +

+ 도움말 텍스트 (선택사항) +

+
+
+ + {/* 푸터: 액션 버튼들 */} + + + + +
+
+``` + +**필수 적용 사항:** + +1. **반응형 크기** + - 모바일: `max-w-[95vw]` (화면 너비의 95%) + - 데스크톱: `sm:max-w-[500px]` (고정 500px) + +2. **헤더 구조** + - DialogTitle: `text-base sm:text-lg` (16px → 18px) + - DialogDescription: `text-xs sm:text-sm` (12px → 14px) + - 항상 제목과 설명 모두 포함 + +3. **컨텐츠 간격** + - 필드 간 간격: `space-y-3 sm:space-y-4` (12px → 16px) + - 각 필드는 `
` 로 감싸기 + +4. **입력 필드 패턴** + - Label: `text-xs sm:text-sm` + 필수 필드는 `*` 표시 + - Input/Select: `h-8 text-xs sm:h-10 sm:text-sm` (32px → 40px) + - 도움말: `text-muted-foreground mt-1 text-[10px] sm:text-xs` + +5. **푸터 버튼** + - 컨테이너: `gap-2 sm:gap-0` (모바일에서 간격, 데스크톱에서 자동) + - 버튼: `h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm` + - 모바일: 같은 크기 (`flex-1`) + - 데스크톱: 자동 크기 (`flex-none`) + - 순서: 취소(outline) → 확인(default) + +6. **접근성** + - Label의 `htmlFor`와 Input의 `id` 매칭 + - Button에 적절한 `onClick` 핸들러 + - Dialog의 `open`과 `onOpenChange` 필수 + +**확인 모달 (간단한 경고/확인):** + +```tsx + + + + 작업 확인 + + 정말로 이 작업을 수행하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + + + + +
+
+``` + +**원칙:** +- 모든 모달은 모바일 우선 반응형 디자인 +- 일관된 크기, 간격, 폰트 크기 사용 +- 사용자가 다른 크기를 명시하지 않으면 `sm:max-w-[500px]` 사용 +- 삭제/위험한 작업은 `variant="destructive"` 사용 + +### 18. 검색 가능한 Select 박스 (Combobox 패턴) + +**적용 조건**: 사용자가 "검색 기능이 있는 Select 박스" 또는 "Combobox"를 명시적으로 요청한 경우만 사용 + +**표준 Combobox 구조 (플로우 관리 기준):** + +```tsx +import { Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +// 상태 관리 +const [open, setOpen] = useState(false); +const [value, setValue] = useState(""); + +// 렌더링 + + + + + + + + + + 항목을 찾을 수 없습니다. + + + {items.map((item) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + + {item.label} + + ))} + + + + + +``` + +**복잡한 데이터 표시 (라벨 + 설명):** + +```tsx + { + setValue(currentValue); + setOpen(false); + }} + className="text-xs sm:text-sm" +> + +
+ {item.label} + {item.description && ( + {item.description} + )} +
+
+``` + +**필수 적용 사항:** + +1. **반응형 크기** + - 버튼 높이: `h-8 sm:h-10` (32px → 40px) + - 텍스트 크기: `text-xs sm:text-sm` (12px → 14px) + - PopoverContent 너비: `width: "var(--radix-popover-trigger-width)"` (트리거와 동일) + +2. **필수 컴포넌트** + - Popover: 드롭다운 컨테이너 + - Command: 검색 및 필터링 기능 + - CommandInput: 검색 입력 필드 + - CommandList: 항목 목록 컨테이너 + - CommandEmpty: 검색 결과 없음 메시지 + - CommandGroup: 항목 그룹 + - CommandItem: 개별 항목 + +3. **아이콘 사용** + - ChevronsUpDown: 드롭다운 표시 아이콘 (오른쪽) + - Check: 선택된 항목 표시 (왼쪽) + +4. **접근성** + - `role="combobox"`: ARIA 역할 명시 + - `aria-expanded={open}`: 열림/닫힘 상태 + - PopoverTrigger에 `asChild` 사용 + +5. **로딩 상태** + ```tsx + + ``` + +**일반 Select vs Combobox 선택 기준:** + +| 상황 | 컴포넌트 | 이유 | +|------|----------|------| +| 항목 5개 이하 | `` | 빠른 선택 | + +**원칙:** +- 사용자가 명시적으로 요청하지 않으면 일반 Select 사용 +- 많은 항목(10개 이상)을 다룰 때는 Combobox 권장 +- 일관된 반응형 크기 유지 +- 검색 플레이스홀더는 구체적으로 작성 + +### 19. Form Validation (폼 검증) + +**입력 필드 상태별 스타일:** + +```tsx +// Default (기본) +className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + +// Error (에러) +className="flex h-10 w-full rounded-md border border-destructive bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive" + +// Success (성공) +className="flex h-10 w-full rounded-md border border-success bg-background px-3 py-2 text-sm +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-success" + +// Disabled (비활성) +className="flex h-10 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm +opacity-50 cursor-not-allowed" +``` + +**Helper Text (도움말 텍스트):** + +```tsx +// 기본 Helper Text +

+ 8자 이상 입력해주세요 +

+ +// Error Message +

+ + 이메일 형식이 올바르지 않습니다 +

+ +// Success Message +

+ + 사용 가능한 이메일입니다 +

+``` + +**Form Label (폼 라벨):** + +```tsx +// 기본 라벨 + + +// 필수 항목 표시 + +``` + +**전체 폼 필드 구조:** + +```tsx +
+ + + {error && ( +

+ + {errorMessage} +

+ )} + {!error && helperText && ( +

{helperText}

+ )} +
+``` + +**실시간 검증 피드백:** + +```tsx +// 로딩 중 (검증 진행) +
+ + +
+ +// 성공 +
+ + +
+ +// 실패 +
+ + +
+``` + +### 20. Loading States (로딩 상태) + +**Spinner (스피너) 크기별:** + +```tsx +// Small + + +// Default + + +// Large + +``` + +**Spinner 색상별:** + +```tsx +// Primary + + +// Muted + + +// White (다크 배경용) + +``` + +**Button Loading:** + +```tsx + +``` + +**Skeleton UI:** + +```tsx +// 텍스트 스켈레톤 +
+
+
+
+
+ +// 카드 스켈레톤 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+``` + +**Progress Bar (진행률):** + +```tsx +// 기본 Progress Bar +
+
+
+ +// 라벨 포함 +
+
+ 업로드 중... + {progress}% +
+
+
+
+
+``` + +**Full Page Loading:** + +```tsx +
+
+ +

로딩 중...

+
+
+``` + +### 21. Empty States (빈 상태) + +**기본 Empty State:** + +```tsx +
+
+ +
+

데이터가 없습니다

+

+ 아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요. +

+ +
+``` + +**검색 결과 없음:** + +```tsx +
+
+ +
+

검색 결과가 없습니다

+

+ "{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요. +

+ +
+``` + +**에러 상태:** + +```tsx +
+
+ +
+

데이터를 불러올 수 없습니다

+

+ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. +

+ +
+``` + +**아이콘 가이드:** +- 데이터 없음: Inbox, Package, FileText +- 검색 결과 없음: Search, SearchX +- 필터 결과 없음: Filter, FilterX +- 에러: AlertCircle, XCircle +- 네트워크 오류: WifiOff, CloudOff +- 권한 없음: Lock, ShieldOff + +--- + +## 추가 프로젝트 규칙 + +- 백엔드 재실행 금지 +- 항상 한글로 답변 +- 이모지 사용 금지 (명시적 요청 없이) +- 심플하고 깔끔한 디자인 유지 + diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 398dd414..f596af97 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -31,13 +31,16 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName } = req.body; + const { name, description, tableName, dbSourceType, dbConnectionId } = + req.body; const userId = (req as any).user?.userId || "system"; console.log("🔍 createFlowDefinition called with:", { name, description, tableName, + dbSourceType, + dbConnectionId, }); if (!name) { @@ -62,7 +65,7 @@ export class FlowController { } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName }, + { name, description, tableName, dbSourceType, dbConnectionId }, userId ); diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 93c59ad1..06c6795b 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -4,6 +4,7 @@ import { Router } from "express"; import { FlowController } from "../controllers/flowController"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); const flowController = new FlowController(); @@ -32,8 +33,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); router.get("/:flowId/steps/counts", flowController.getAllStepCounts); // ==================== 데이터 이동 ==================== -router.post("/move", flowController.moveData); -router.post("/move-batch", flowController.moveBatchData); +router.post("/move", authenticateToken, flowController.moveData); +router.post("/move-batch", authenticateToken, flowController.moveBatchData); // ==================== 오딧 로그 ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); diff --git a/backend-node/src/services/externalDbHelper.ts b/backend-node/src/services/externalDbHelper.ts index 2a774ef2..9a8b4f7d 100644 --- a/backend-node/src/services/externalDbHelper.ts +++ b/backend-node/src/services/externalDbHelper.ts @@ -7,7 +7,7 @@ import { Pool as PgPool } from "pg"; import * as mysql from "mysql2/promise"; import db from "../database/db"; -import { CredentialEncryption } from "../utils/credentialEncryption"; +import { PasswordEncryption } from "../utils/passwordEncryption"; import { getConnectionTestQuery, getPlaceholder, @@ -31,24 +31,13 @@ interface ExternalDbConnection { // 외부 DB 연결 풀 캐시 (타입별로 다른 풀 객체) const connectionPools = new Map(); -// 비밀번호 복호화 유틸 -const credentialEncryption = new CredentialEncryption( - process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-change-in-production" -); - /** * 외부 DB 연결 정보 조회 */ async function getExternalConnection( connectionId: number ): Promise { - const query = ` - SELECT - id, connection_name, db_type, host, port, - database_name, username, encrypted_password, is_active - FROM external_db_connections - WHERE id = $1 AND is_active = true - `; + const query = `SELECT * FROM external_db_connections WHERE id = $1 AND is_active = 'Y'`; const result = await db.query(query, [connectionId]); @@ -58,13 +47,14 @@ async function getExternalConnection( const row = result[0]; - // 비밀번호 복호화 + // 비밀번호 복호화 (암호화된 비밀번호는 password 컬럼에 저장됨) let decryptedPassword = ""; try { - decryptedPassword = credentialEncryption.decrypt(row.encrypted_password); + decryptedPassword = PasswordEncryption.decrypt(row.password); } catch (error) { console.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error); - throw new Error("외부 DB 비밀번호 복호화에 실패했습니다"); + // 복호화 실패 시 원본 비밀번호 사용 (fallback) + decryptedPassword = row.password; } return { diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 03fd18c6..39ab6013 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -161,6 +161,28 @@ export class FlowDataMoveService { } // 5. 감사 로그 기록 + let dbConnectionName = null; + if ( + flowDefinition.dbSourceType === "external" && + flowDefinition.dbConnectionId + ) { + // 외부 DB인 경우 연결 이름 조회 + try { + const connResult = await client.query( + `SELECT connection_name FROM external_db_connections WHERE id = $1`, + [flowDefinition.dbConnectionId] + ); + if (connResult.rows && connResult.rows.length > 0) { + dbConnectionName = connResult.rows[0].connection_name; + } + } catch (error) { + console.warn("외부 DB 연결 이름 조회 실패:", error); + } + } else { + // 내부 DB인 경우 + dbConnectionName = "내부 데이터베이스"; + } + await this.logDataMove(client, { flowId, fromStepId, @@ -173,6 +195,11 @@ export class FlowDataMoveService { statusFrom: fromStep.statusValue, statusTo: toStep.statusValue, userId, + dbConnectionId: + flowDefinition.dbSourceType === "external" + ? flowDefinition.dbConnectionId + : null, + dbConnectionName, }); return { @@ -361,8 +388,9 @@ export class FlowDataMoveService { move_type, source_table, target_table, source_data_id, target_data_id, status_from, status_to, - changed_by, note - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + changed_by, note, + db_connection_id, db_connection_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) `; await client.query(query, [ @@ -378,6 +406,8 @@ export class FlowDataMoveService { params.statusTo, params.userId, params.note || null, + params.dbConnectionId || null, + params.dbConnectionName || null, ]); } @@ -452,6 +482,8 @@ export class FlowDataMoveService { targetDataId: row.target_data_id, statusFrom: row.status_from, statusTo: row.status_to, + dbConnectionId: row.db_connection_id, + dbConnectionName: row.db_connection_name, })); } @@ -496,6 +528,8 @@ export class FlowDataMoveService { targetDataId: row.target_data_id, statusFrom: row.status_from, statusTo: row.status_to, + dbConnectionId: row.db_connection_id, + dbConnectionName: row.db_connection_name, })); } @@ -718,7 +752,21 @@ export class FlowDataMoveService { // 3. 외부 연동 처리는 생략 (외부 DB 자체가 외부이므로) - // 4. 감사 로그 기록 (내부 DB에) + // 4. 외부 DB 연결 이름 조회 + let dbConnectionName = null; + try { + const connResult = await db.query( + `SELECT connection_name FROM external_db_connections WHERE id = $1`, + [dbConnectionId] + ); + if (connResult.length > 0) { + dbConnectionName = connResult[0].connection_name; + } + } catch (error) { + console.warn("외부 DB 연결 이름 조회 실패:", error); + } + + // 5. 감사 로그 기록 (내부 DB에) // 외부 DB는 내부 DB 트랜잭션 외부이므로 직접 쿼리 실행 const auditQuery = ` INSERT INTO flow_audit_log ( @@ -726,8 +774,9 @@ export class FlowDataMoveService { move_type, source_table, target_table, source_data_id, target_data_id, status_from, status_to, - changed_by, note - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + changed_by, note, + db_connection_id, db_connection_name + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) `; await db.query(auditQuery, [ @@ -743,6 +792,8 @@ export class FlowDataMoveService { toStep.statusValue || null, // statusTo userId, `외부 DB (${dbType}) 데이터 이동`, + dbConnectionId, + dbConnectionName, ]); return { diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 0c84fbeb..4368ae1a 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -182,6 +182,9 @@ export interface FlowAuditLog { targetDataId?: string; statusFrom?: string; statusTo?: string; + // 외부 DB 연결 정보 + dbConnectionId?: number; + dbConnectionName?: string; // 조인 필드 fromStepName?: string; toStepName?: string; diff --git a/frontend/components/flow/FlowConditionBuilder.tsx b/frontend/components/flow/FlowConditionBuilder.tsx index c3572dc8..0440d049 100644 --- a/frontend/components/flow/FlowConditionBuilder.tsx +++ b/frontend/components/flow/FlowConditionBuilder.tsx @@ -243,14 +243,20 @@ export function FlowConditionBuilder({ - {columns.map((col) => ( - -
- {col.displayName || col.columnName} - ({col.dataType}) -
-
- ))} + {columns.map((col, idx) => { + const columnName = col.column_name || col.columnName || ""; + const dataType = col.data_type || col.dataType || ""; + const displayName = col.displayName || col.display_name || columnName; + + return ( + +
+ {displayName} + ({dataType}) +
+
+ ); + })}
)} diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index 21a660bb..8dc59a28 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -666,8 +666,12 @@ export function FlowStepPanel({ {loadingColumns ? "컬럼 로딩 중..." : formData.statusColumn - ? columns.find((col) => col.columnName === formData.statusColumn)?.columnName || - formData.statusColumn + ? (() => { + const col = columns.find( + (c) => (c.column_name || c.columnName) === formData.statusColumn, + ); + return col ? col.column_name || col.columnName : formData.statusColumn; + })() : "상태 컬럼 선택"} @@ -678,27 +682,32 @@ export function FlowStepPanel({ 컬럼을 찾을 수 없습니다. - {columns.map((column) => ( - { - setFormData({ ...formData, statusColumn: column.columnName }); - setOpenStatusColumnCombobox(false); - }} - > - -
-
{column.columnName}
-
({column.dataType})
-
-
- ))} + {columns.map((column, idx) => { + const columnName = column.column_name || column.columnName || ""; + const dataType = column.data_type || column.dataType || ""; + + return ( + { + setFormData({ ...formData, statusColumn: columnName }); + setOpenStatusColumnCombobox(false); + }} + > + +
+
{columnName}
+
({dataType})
+
+
+ ); + })}
diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index dc4ad00e..152e233e 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -397,6 +397,7 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) { 데이터 ID 상태 변경 변경자 + DB 연결 테이블 @@ -450,6 +451,21 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) { )} {log.changedBy} + + {log.dbConnectionName ? ( + + {log.dbConnectionName} + + ) : ( + - + )} + {log.sourceTable || "-"} {log.targetTable && log.targetTable !== log.sourceTable && ( diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index 08fb553b..7d61293a 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -21,6 +21,12 @@ import { const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api"; +// 토큰 가져오기 +function getAuthToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("authToken") || sessionStorage.getItem("authToken"); +} + // ============================================ // 플로우 정의 API // ============================================ @@ -364,10 +370,12 @@ export async function getAllStepCounts(flowId: number): Promise> { try { + const token = getAuthToken(); const response = await fetch(`${API_BASE}/flow/move`, { method: "POST", headers: { "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), }, credentials: "include", body: JSON.stringify(data), @@ -404,10 +412,12 @@ export async function moveBatchData( data: MoveBatchDataRequest, ): Promise> { try { + const token = getAuthToken(); const response = await fetch(`${API_BASE}/flow/move-batch`, { method: "POST", headers: { "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), }, credentials: "include", body: JSON.stringify(data), diff --git a/frontend/types/flow.ts b/frontend/types/flow.ts index d6f05a5e..5fd17e66 100644 --- a/frontend/types/flow.ts +++ b/frontend/types/flow.ts @@ -148,6 +148,15 @@ export interface FlowAuditLog { note?: string; fromStepName?: string; toStepName?: string; + moveType?: "status" | "table" | "both"; + sourceTable?: string; + targetTable?: string; + sourceDataId?: string; + targetDataId?: string; + statusFrom?: string; + statusTo?: string; + dbConnectionId?: number; + dbConnectionName?: string; } // ============================================