+```
+
+**표준 색상 토큰:**
+
+- `bg-background` / `text-foreground`: 기본 배경/텍스트
+- `bg-card` / `text-card-foreground`: 카드 배경/텍스트
+- `bg-muted` / `text-muted-foreground`: 보조 배경/텍스트
+- `bg-primary` / `text-primary`: 메인 액션
+- `bg-destructive` / `text-destructive`: 삭제/에러
+- `border-border`: 테두리
+- `ring-ring`: 포커스 링
+
+## 3. Typography (타이포그래피)
+
+### 표준 텍스트 크기와 가중치
+
+```tsx
+// 페이지 제목
+
+
+// 섹션 제목
+
+
+
+
+// 본문 텍스트
+
+
+// 보조 텍스트
+
+
+
+// 라벨
+
+```
+
+## 4. Spacing System (간격)
+
+### 일관된 간격 (4px 기준)
+
+```tsx
+// 페이지 레벨 간격
+ // 24px
+
+// 섹션 레벨 간격
+
// 16px
+
+// 필드 레벨 간격
+
// 8px
+
+// 패딩
+
// 24px (카드)
+
// 16px (내부 섹션)
+
+// 갭
+
// 16px (flex/grid)
+
// 8px (버튼 그룹)
+```
+
+## 5. 검색 툴바 (Toolbar)
+
+### 패턴 A: 통합 검색 영역 (권장)
+
+```tsx
+
+ {/* 검색 및 액션 영역 */}
+
+ {/* 검색 영역 */}
+
+ {/* 통합 검색 */}
+
+
+ {/* 고급 검색 토글 */}
+
+ 고급 검색
+
+
+
+ {/* 액션 버튼 영역 */}
+
+
+ 총{" "}
+
+ {count.toLocaleString()}
+ {" "}
+ 건
+
+
+
+ 등록
+
+
+
+
+ {/* 고급 검색 옵션 */}
+ {showAdvanced && (
+
+ )}
+
+```
+
+### 패턴 B: 제목 + 검색 + 버튼 한 줄 (공간 효율적)
+
+```tsx
+{
+ /* 상단 헤더: 제목 + 검색 + 버튼 */
+}
+
+ {/* 왼쪽: 제목 */}
+
페이지 제목
+
+ {/* 오른쪽: 검색 + 버튼 */}
+
+ {/* 필터 선택 */}
+
+
+
+
+
+
+
+
+ {/* 검색 입력 */}
+
+
+
+
+ {/* 초기화 버튼 */}
+
+ 초기화
+
+
+ {/* 주요 액션 버튼 */}
+
+
+ 등록
+
+
+ {/* 조건부 버튼 (선택 시) */}
+ {selectedCount > 0 && (
+
+ 삭제 ({selectedCount})
+
+ )}
+
+
;
+```
+
+**필수 적용 사항:**
+
+- ❌ 검색 영역에 박스/테두리 사용 금지
+- ✅ 검색창 권장 너비: `w-full sm:w-[240px]` ~ `sm:w-[400px]`
+- ✅ 필터/Select 권장 너비: `w-full sm:w-[160px]` ~ `sm:w-[200px]`
+- ✅ 고급 검색 필드: placeholder만 사용 (라벨 제거)
+- ✅ 검색 아이콘: `Search` (lucide-react)
+- ✅ Input/Select 높이: `h-10` (40px)
+- ✅ 상단 헤더에 `relative` 추가 (드롭다운 표시용)
+
+## 6. Button (버튼)
+
+### 표준 버튼 variants와 크기
+
+```tsx
+// Primary 액션
+
+
+ 등록
+
+
+// Secondary 액션
+
+ 취소
+
+
+// Ghost 버튼 (아이콘 전용)
+
+
+
+
+// Destructive
+
+ 삭제
+
+```
+
+**표준 크기:**
+
+- `h-10`: 기본 버튼 (40px)
+- `h-9`: 작은 버튼 (36px)
+- `h-8`: 아이콘 버튼 (32px)
+
+**아이콘 크기:**
+
+- `h-4 w-4`: 버튼 내 아이콘 (16px)
+
+## 7. Input (입력 필드)
+
+### 표준 Input 스타일
+
+```tsx
+// 기본
+
+
+// 검색 (아이콘 포함)
+
+
+
+
+
+// 로딩/액티브
+
+
+// 비활성화
+
+```
+
+**필수 적용 사항:**
+
+- 높이: `h-10` (40px)
+- 텍스트: `text-sm`
+- 포커스: 자동 적용 (`ring-2 ring-ring`)
+
+## 8. Table & Card (테이블과 카드)
+
+### 반응형 테이블/카드 구조
+
+```tsx
+// 실제 데이터 렌더링
+return (
+ <>
+ {/* 데스크톱 테이블 뷰 (lg 이상) */}
+
+
+
+
+ 컬럼
+
+
+
+
+ 데이터
+
+
+
+
+
+ {/* 모바일/태블릿 카드 뷰 (lg 미만) */}
+
+ {items.map((item) => (
+
+ {/* 헤더 */}
+
+
+
{item.name}
+
{item.id}
+
+
+
+
+ {/* 정보 */}
+
+
+ 필드
+ {item.value}
+
+
+
+ {/* 액션 */}
+
+
+
+ 액션
+
+
+
+ ))}
+
+ >
+);
+```
+
+**테이블 표준:**
+
+- 헤더: `h-12` (48px), `bg-muted/50`, `font-semibold`
+- 데이터 행: `h-16` (64px), `hover:bg-muted/50`
+- 텍스트: `text-sm`
+
+**카드 표준:**
+
+- 컨테이너: `rounded-lg border bg-card p-4 shadow-sm`
+- 헤더 제목: `text-base font-semibold`
+- 부제목: `text-sm text-muted-foreground`
+- 정보 라벨: `text-sm text-muted-foreground`
+- 정보 값: `text-sm font-medium`
+- 버튼: `h-9 flex-1 gap-2 text-sm`
+
+## 9. Loading States (로딩 상태)
+
+### Skeleton UI 패턴
+
+```tsx
+// 테이블 스켈레톤 (데스크톱)
+
+
+ ...
+
+ {Array.from({ length: 10 }).map((_, index) => (
+
+
+
+
+
+ ))}
+
+
+
+
+// 카드 스켈레톤 (모바일/태블릿)
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ ))}
+
+```
+
+## 10. Empty States (빈 상태)
+
+### 표준 Empty State
+
+```tsx
+
+```
+
+## 11. Error States (에러 상태)
+
+### 표준 에러 메시지
+
+```tsx
+
+
+
+ 오류가 발생했습니다
+
+
+ ✕
+
+
+
{errorMessage}
+
+```
+
+## 12. Responsive Design (반응형)
+
+### Breakpoints
+
+- `sm`: 640px (모바일 가로/태블릿)
+- `md`: 768px (태블릿)
+- `lg`: 1024px (노트북)
+- `xl`: 1280px (데스크톱)
+
+### 모바일 우선 패턴
+
+```tsx
+// 레이아웃
+
+
+// 그리드
+
+
+// 검색창
+
+
+// 테이블/카드 전환
+
{/* 데스크톱 테이블 */}
+
{/* 모바일 카드 */}
+
+// 간격
+
+
+```
+
+## 13. 좌우 레이아웃 (Side-by-Side Layout)
+
+### 사이드바 + 메인 영역 구조
+
+```tsx
+
+ {/* 좌측 사이드바 (20-30%) */}
+
+
+
사이드바 제목
+
+ {/* 사이드바 컨텐츠 */}
+
+
+
+
+ {/* 우측 메인 영역 (70-80%) */}
+
+
+
메인 제목
+
+ {/* 메인 컨텐츠 */}
+
{/* 컨텐츠 */}
+
+
+
+```
+
+**필수 적용 사항:**
+
+- ✅ 좌우 구분: `border-r` 사용 (세로 구분선)
+- ✅ 간격: `gap-6` (24px)
+- ✅ 사이드바 패딩: `pr-6` (오른쪽 24px)
+- ✅ 메인 영역 패딩: `pl-0` (gap으로 간격 확보)
+- ✅ 비율: 20:80 또는 30:70
+- ❌ 과도한 구분선 사용 금지 (세로 구분선 1개만)
+- ❌ 사이드바와 메인 영역 각각에 추가 border 금지
+
+## 14. Custom Dropdown (커스텀 드롭다운)
+
+### 커스텀 Select/Dropdown 구조
+
+```tsx
+{
+ /* 드롭다운 컨테이너 */
+}
+
+
+ {/* 트리거 버튼 */}
+
setIsOpen(!isOpen)}
+ className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {value || "선택하세요"}
+
+
+
+
+
+
+ {/* 드롭다운 메뉴 */}
+ {isOpen && (
+
+ {/* 검색 (선택사항) */}
+
+ setSearchText(e.target.value)}
+ className="h-8 text-sm"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {/* 옵션 목록 */}
+
+ {options.map((option) => (
+
{
+ setValue(option.value);
+ setIsOpen(false);
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+ )}
+
+
;
+```
+
+**필수 적용 사항:**
+
+- ✅ z-index: `z-[100]` (다른 요소 위에 표시)
+- ✅ 그림자: `shadow-lg` (명확한 레이어 구분)
+- ✅ 최소 너비: `min-w-[200px]` (내용이 잘리지 않도록)
+- ✅ 최대 높이: `max-h-48` (스크롤 가능)
+- ✅ 애니메이션: 화살표 아이콘 회전 (`rotate-180`)
+- ✅ 부모 요소: `relative` 클래스 필요
+- ⚠️ 부모에 `overflow-hidden` 사용 시 드롭다운 잘림 주의
+
+**드롭다운이 잘릴 때 해결방법:**
+
+```tsx
+// 부모 요소의 overflow 제거
+
// overflow-hidden 제거
+
+// 또는 상단 헤더에 relative 추가
+
// 드롭다운 포지셔닝 기준점
+```
+
+## 15. Scroll to Top Button
+
+### 모바일/태블릿 전용 버튼
+
+```tsx
+import { ScrollToTop } from "@/components/common/ScrollToTop";
+
+// 페이지에 추가
+
;
+```
+
+**특징:**
+
+- 데스크톱에서 숨김 (`lg:hidden`)
+- 스크롤 200px 이상 시 나타남
+- 부드러운 페이드 인/아웃 애니메이션
+- 오른쪽 하단 고정 위치
+- 원형 디자인 (`rounded-full`)
+
+## 14. Accessibility (접근성)
+
+### 필수 적용 사항
+
+```tsx
+// Label과 Input 연결
+
+ 라벨
+
+
+
+// 버튼에 aria-label
+
+
+
+
+// Switch에 aria-label
+
+
+// 포커스 표시 (자동 적용)
+focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
+```
+
+## 15. Class 순서 (일관성)
+
+### 표준 클래스 작성 순서
+
+1. Layout: `flex`, `grid`, `block`
+2. Position: `fixed`, `absolute`, `relative`
+3. Sizing: `w-full`, `h-10`
+4. Spacing: `p-4`, `m-2`, `gap-4`
+5. Typography: `text-sm`, `font-medium`
+6. Colors: `bg-primary`, `text-white`
+7. Border: `border`, `rounded-md`
+8. Effects: `shadow-sm`, `opacity-50`
+9. States: `hover:`, `focus:`, `disabled:`
+10. Responsive: `sm:`, `md:`, `lg:`
+
+## 16. 금지 사항
+
+### ❌ 절대 사용하지 말 것
+
+1. 하드코딩된 색상 (`bg-gray-50`, `text-blue-500` 등)
+2. 인라인 스타일로 색상 지정 (`style={{ color: '#3b82f6' }}`)
+3. 포커스 스타일 제거 (`outline-none`만 단독 사용)
+4. 중첩된 박스 (Card 안에 Card, Border 안에 Border)
+5. 검색 영역에 불필요한 박스/테두리
+6. 검색 필드에 라벨 (placeholder만 사용)
+7. 반응형 무시 (데스크톱 전용 스타일)
+8. **이모지 사용** (사용자가 명시적으로 요청하지 않는 한 절대 사용 금지)
+9. 과도한 구분선 사용 (최소한으로 유지)
+10. 드롭다운 부모에 `overflow-hidden` (잘림 발생)
+
+## 17. 체크리스트
+
+새로운 관리자 페이지 작성 시 다음을 확인하세요:
+
+### 페이지 레벨
+
+- [ ] `bg-background` 사용 (하드코딩 금지)
+- [ ] `space-y-6 p-6` 구조
+- [ ] 페이지 헤더에 `border-b pb-4`
+- [ ] `ScrollToTop` 컴포넌트 포함
+
+### 검색 툴바
+
+- [ ] 박스/테두리 없음
+- [ ] 검색창 최대 너비 `sm:w-[400px]`
+- [ ] 고급 검색 필드에 라벨 없음 (placeholder만)
+- [ ] 반응형 레이아웃 적용
+
+### 테이블/카드
+
+- [ ] 데스크톱: 테이블 (`hidden lg:block`)
+- [ ] 모바일: 카드 (`lg:hidden`)
+- [ ] 표준 높이와 간격 적용
+- [ ] 로딩/Empty 상태 구현
+
+### 버튼
+
+- [ ] 표준 variants 사용
+- [ ] 표준 높이: `h-10`, `h-9`, `h-8`
+- [ ] 아이콘 크기: `h-4 w-4`
+- [ ] `gap-2`로 아이콘과 텍스트 간격
+
+### 반응형
+
+- [ ] 모바일 우선 디자인
+- [ ] Breakpoints 적용 (`sm:`, `lg:`)
+- [ ] 테이블/카드 전환
+- [ ] Scroll to Top 버튼
+
+### 접근성
+
+- [ ] Label `htmlFor` / Input `id` 연결
+- [ ] 버튼 `aria-label`
+- [ ] Switch `aria-label`
+- [ ] 포커스 표시 유지
+
+## 참고 파일
+
+완성된 예시:
+
+### 기본 패턴
+
+- [사용자 관리 페이지](
) - 기본 페이지 구조
+- [검색 툴바](mdc:frontend/components/admin/UserToolbar.tsx) - 패턴 A (통합 검색)
+- [테이블/카드](mdc:frontend/components/admin/UserTable.tsx) - 반응형 테이블/카드
+- [Scroll to Top](mdc:frontend/components/common/ScrollToTop.tsx) - 스크롤 버튼
+
+### 고급 패턴
+
+- [메뉴 관리 페이지]() - 좌우 레이아웃 + 패턴 B (제목+검색+버튼)
+- [메뉴 관리 컴포넌트](mdc:frontend/components/admin/MenuManagement.tsx) - 커스텀 드롭다운 + 좌우 레이아웃
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("");
+
+// 렌더링
+
+
+
+ {value
+ ? items.find((item) => item.value === value)?.label
+ : "항목 선택"}
+
+
+
+
+
+
+
+
+ 항목을 찾을 수 없습니다.
+
+
+ {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
+
+ {loading ? "로딩 중..." : value ? "선택됨" : "항목 선택"}
+
+
+ ```
+
+**일반 Select vs Combobox 선택 기준:**
+
+| 상황 | 컴포넌트 | 이유 |
+|------|----------|------|
+| 항목 5개 이하 | `
` | 검색 불필요 |
+| 항목 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/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md b/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md
new file mode 100644
index 00000000..c2934906
--- /dev/null
+++ b/EXTERNAL_REST_API_IMPLEMENTATION_COMPLETE.md
@@ -0,0 +1,399 @@
+# 외부 커넥션 관리 REST API 지원 구현 완료 보고서
+
+## 📋 구현 개요
+
+`/admin/external-connections` 페이지에 REST API 연결 관리 기능을 성공적으로 추가했습니다.
+이제 외부 데이터베이스 연결과 REST API 연결을 탭을 통해 통합 관리할 수 있습니다.
+
+---
+
+## ✅ 구현 완료 사항
+
+### 1. 데이터베이스 구조
+
+**파일**: `/Users/dohyeonsu/Documents/ERP-node/db/create_external_rest_api_connections.sql`
+
+- ✅ `external_rest_api_connections` 테이블 생성
+- ✅ 인증 타입 (none, api-key, bearer, basic, oauth2) 지원
+- ✅ 헤더 정보 JSONB 저장
+- ✅ 테스트 결과 저장 (last_test_date, last_test_result, last_test_message)
+- ✅ 샘플 데이터 포함 (기상청 API, JSONPlaceholder)
+
+### 2. 백엔드 구현
+
+#### 타입 정의
+
+**파일**: `backend-node/src/types/externalRestApiTypes.ts`
+
+- ✅ ExternalRestApiConnection 인터페이스
+- ✅ ExternalRestApiConnectionFilter 인터페이스
+- ✅ RestApiTestRequest 인터페이스
+- ✅ RestApiTestResult 인터페이스
+- ✅ AuthType 타입 정의
+
+#### 서비스 계층
+
+**파일**: `backend-node/src/services/externalRestApiConnectionService.ts`
+
+- ✅ CRUD 메서드 (getConnections, getConnectionById, createConnection, updateConnection, deleteConnection)
+- ✅ 연결 테스트 메서드 (testConnection, testConnectionById)
+- ✅ 민감 정보 암호화/복호화 (AES-256-GCM)
+- ✅ 유효성 검증
+- ✅ 인증 타입별 헤더 구성
+
+#### API 라우트
+
+**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
+
+- ✅ GET `/api/external-rest-api-connections` - 목록 조회
+- ✅ GET `/api/external-rest-api-connections/:id` - 상세 조회
+- ✅ POST `/api/external-rest-api-connections` - 연결 생성
+- ✅ PUT `/api/external-rest-api-connections/:id` - 연결 수정
+- ✅ DELETE `/api/external-rest-api-connections/:id` - 연결 삭제
+- ✅ POST `/api/external-rest-api-connections/test` - 연결 테스트 (데이터 기반)
+- ✅ POST `/api/external-rest-api-connections/:id/test` - 연결 테스트 (ID 기반)
+
+#### 라우트 등록
+
+**파일**: `backend-node/src/app.ts`
+
+- ✅ externalRestApiConnectionRoutes import
+- ✅ `/api/external-rest-api-connections` 경로 등록
+
+### 3. 프론트엔드 구현
+
+#### API 클라이언트
+
+**파일**: `frontend/lib/api/externalRestApiConnection.ts`
+
+- ✅ ExternalRestApiConnectionAPI 클래스
+- ✅ CRUD 메서드
+- ✅ 연결 테스트 메서드
+- ✅ 지원되는 인증 타입 조회
+
+#### 헤더 관리 컴포넌트
+
+**파일**: `frontend/components/admin/HeadersManager.tsx`
+
+- ✅ 동적 키-값 추가/삭제
+- ✅ 테이블 형식 UI
+- ✅ 실시간 업데이트
+
+#### 인증 설정 컴포넌트
+
+**파일**: `frontend/components/admin/AuthenticationConfig.tsx`
+
+- ✅ 인증 타입 선택
+- ✅ API Key 설정 (header/query 선택)
+- ✅ Bearer Token 설정
+- ✅ Basic Auth 설정
+- ✅ OAuth 2.0 설정
+- ✅ 타입별 동적 UI 표시
+
+#### REST API 연결 모달
+
+**파일**: `frontend/components/admin/RestApiConnectionModal.tsx`
+
+- ✅ 기본 정보 입력 (연결명, 설명, URL)
+- ✅ 헤더 관리 통합
+- ✅ 인증 설정 통합
+- ✅ 고급 설정 (타임아웃, 재시도)
+- ✅ 연결 테스트 기능
+- ✅ 테스트 결과 표시
+- ✅ 유효성 검증
+
+#### REST API 연결 목록 컴포넌트
+
+**파일**: `frontend/components/admin/RestApiConnectionList.tsx`
+
+- ✅ 연결 목록 테이블
+- ✅ 검색 기능 (연결명, URL)
+- ✅ 필터링 (인증 타입, 활성 상태)
+- ✅ 연결 테스트 버튼 및 결과 표시
+- ✅ 편집/삭제 기능
+- ✅ 마지막 테스트 정보 표시
+
+#### 메인 페이지 탭 구조
+
+**파일**: `frontend/app/(main)/admin/external-connections/page.tsx`
+
+- ✅ 탭 UI 추가 (Database / REST API)
+- ✅ 데이터베이스 연결 탭 (기존 기능)
+- ✅ REST API 연결 탭 (신규 기능)
+- ✅ 탭 전환 상태 관리
+
+---
+
+## 🎯 주요 기능
+
+### 1. 탭 전환
+
+- 데이터베이스 연결 관리 ↔ REST API 연결 관리 간 탭으로 전환
+- 각 탭은 독립적으로 동작
+
+### 2. REST API 연결 관리
+
+- **연결명**: 고유한 이름으로 연결 식별
+- **기본 URL**: API의 베이스 URL
+- **헤더 설정**: 키-값 쌍으로 HTTP 헤더 관리
+- **인증 설정**: 5가지 인증 타입 지원
+ - 인증 없음 (none)
+ - API Key (header 또는 query parameter)
+ - Bearer Token
+ - Basic Auth
+ - OAuth 2.0
+
+### 3. 연결 테스트
+
+- 저장 전 연결 테스트 가능
+- 테스트 엔드포인트 지정 가능 (선택)
+- 응답 시간, 상태 코드 표시
+- 테스트 결과 데이터베이스 저장
+
+### 4. 보안
+
+- 민감 정보 암호화 (API 키, 토큰, 비밀번호)
+- AES-256-GCM 알고리즘 사용
+- 환경 변수로 암호화 키 관리
+
+---
+
+## 📁 생성된 파일 목록
+
+### 데이터베이스
+
+- `db/create_external_rest_api_connections.sql`
+
+### 백엔드
+
+- `backend-node/src/types/externalRestApiTypes.ts`
+- `backend-node/src/services/externalRestApiConnectionService.ts`
+- `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
+
+### 프론트엔드
+
+- `frontend/lib/api/externalRestApiConnection.ts`
+- `frontend/components/admin/HeadersManager.tsx`
+- `frontend/components/admin/AuthenticationConfig.tsx`
+- `frontend/components/admin/RestApiConnectionModal.tsx`
+- `frontend/components/admin/RestApiConnectionList.tsx`
+
+### 수정된 파일
+
+- `backend-node/src/app.ts` (라우트 등록)
+- `frontend/app/(main)/admin/external-connections/page.tsx` (탭 구조)
+
+---
+
+## 🚀 사용 방법
+
+### 1. 데이터베이스 테이블 생성
+
+SQL 스크립트를 실행하세요:
+
+```bash
+psql -U postgres -d your_database -f db/create_external_rest_api_connections.sql
+```
+
+### 2. 백엔드 재시작
+
+암호화 키 환경 변수 설정 (선택):
+
+```bash
+export DB_PASSWORD_SECRET="your-secret-key-32-characters-long"
+```
+
+백엔드 재시작:
+
+```bash
+cd backend-node
+npm run dev
+```
+
+### 3. 프론트엔드 접속
+
+브라우저에서 다음 URL로 접속:
+
+```
+http://localhost:3000/admin/external-connections
+```
+
+### 4. REST API 연결 추가
+
+1. "REST API 연결" 탭 클릭
+2. "새 연결 추가" 버튼 클릭
+3. 연결 정보 입력:
+ - 연결명 (필수)
+ - 기본 URL (필수)
+ - 헤더 설정
+ - 인증 설정
+4. 연결 테스트 (선택)
+5. 저장
+
+---
+
+## 🧪 테스트 시나리오
+
+### 테스트 1: 인증 없는 공개 API
+
+```
+연결명: JSONPlaceholder
+기본 URL: https://jsonplaceholder.typicode.com
+인증 타입: 인증 없음
+테스트 엔드포인트: /posts/1
+```
+
+### 테스트 2: API Key (Query Parameter)
+
+```
+연결명: 기상청 API
+기본 URL: https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0
+인증 타입: API Key
+키 위치: Query Parameter
+키 이름: serviceKey
+키 값: [your-api-key]
+테스트 엔드포인트: /getUltraSrtNcst
+```
+
+### 테스트 3: Bearer Token
+
+```
+연결명: GitHub API
+기본 URL: https://api.github.com
+인증 타입: Bearer Token
+토큰: ghp_your_token_here
+헤더:
+ - Accept: application/vnd.github.v3+json
+ - User-Agent: YourApp
+테스트 엔드포인트: /user
+```
+
+---
+
+## 🔧 고급 설정
+
+### 타임아웃 설정
+
+- 기본값: 30000ms (30초)
+- 범위: 1000ms ~ 120000ms
+
+### 재시도 설정
+
+- 재시도 횟수: 0~5회
+- 재시도 간격: 100ms ~ 10000ms
+
+### 헤더 관리
+
+- 동적 추가/삭제
+- 일반적인 헤더:
+ - `Content-Type: application/json`
+ - `Accept: application/json`
+ - `User-Agent: YourApp/1.0`
+
+---
+
+## 🔒 보안 고려사항
+
+### 암호화
+
+- API 키, 토큰, 비밀번호는 자동 암호화
+- AES-256-GCM 알고리즘 사용
+- 환경 변수 `DB_PASSWORD_SECRET`로 키 관리
+
+### 권한
+
+- 관리자 권한만 접근 가능
+- 회사별 데이터 분리 (`company_code`)
+
+### 테스트 제한
+
+- 동시 테스트 실행 제한
+- 타임아웃 강제 적용
+
+---
+
+## 📊 데이터베이스 스키마
+
+```sql
+external_rest_api_connections
+├── id (SERIAL PRIMARY KEY)
+├── connection_name (VARCHAR(100) UNIQUE) -- 연결명
+├── description (TEXT) -- 설명
+├── base_url (VARCHAR(500)) -- 기본 URL
+├── default_headers (JSONB) -- 헤더 (키-값)
+├── auth_type (VARCHAR(20)) -- 인증 타입
+├── auth_config (JSONB) -- 인증 설정
+├── timeout (INTEGER) -- 타임아웃
+├── retry_count (INTEGER) -- 재시도 횟수
+├── retry_delay (INTEGER) -- 재시도 간격
+├── company_code (VARCHAR(20)) -- 회사 코드
+├── is_active (CHAR(1)) -- 활성 상태
+├── created_date (TIMESTAMP) -- 생성일
+├── created_by (VARCHAR(50)) -- 생성자
+├── updated_date (TIMESTAMP) -- 수정일
+├── updated_by (VARCHAR(50)) -- 수정자
+├── last_test_date (TIMESTAMP) -- 마지막 테스트 일시
+├── last_test_result (CHAR(1)) -- 마지막 테스트 결과
+└── last_test_message (TEXT) -- 마지막 테스트 메시지
+```
+
+---
+
+## 🎉 완료 요약
+
+### 구현 완료
+
+- ✅ 데이터베이스 테이블 생성
+- ✅ 백엔드 API (CRUD + 테스트)
+- ✅ 프론트엔드 UI (탭 + 모달 + 목록)
+- ✅ 헤더 관리 기능
+- ✅ 5가지 인증 타입 지원
+- ✅ 연결 테스트 기능
+- ✅ 민감 정보 암호화
+
+### 테스트 완료
+
+- ✅ API 엔드포인트 테스트
+- ✅ UI 컴포넌트 통합
+- ✅ 탭 전환 기능
+- ✅ CRUD 작업
+- ✅ 연결 테스트
+
+### 문서 완료
+
+- ✅ 계획서 (PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md)
+- ✅ 완료 보고서 (본 문서)
+- ✅ SQL 스크립트 (주석 포함)
+
+---
+
+## 🚀 다음 단계 (선택 사항)
+
+### 향후 확장 가능성
+
+1. **엔드포인트 프리셋 관리**
+
+ - 자주 사용하는 엔드포인트 저장
+ - 빠른 호출 지원
+
+2. **요청 템플릿**
+
+ - HTTP 메서드별 요청 바디 템플릿
+ - 변수 치환 기능
+
+3. **응답 매핑**
+
+ - API 응답을 내부 데이터 구조로 변환
+ - 매핑 룰 설정
+
+4. **로그 및 모니터링**
+ - API 호출 이력 기록
+ - 응답 시간 모니터링
+ - 오류율 추적
+
+---
+
+**구현 완료일**: 2025-10-21
+**버전**: 1.0
+**개발자**: AI Assistant
+**상태**: 완료 ✅
diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md
new file mode 100644
index 00000000..42145a94
--- /dev/null
+++ b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md
@@ -0,0 +1,759 @@
+# 외부 커넥션 관리 REST API 지원 확장 계획서
+
+## 📋 프로젝트 개요
+
+### 목적
+
+현재 외부 데이터베이스 연결만 관리하는 `/admin/external-connections` 페이지에 REST API 연결 관리 기능을 추가하여, DB와 REST API 커넥션을 통합 관리할 수 있도록 확장합니다.
+
+### 현재 상황
+
+- **기존 기능**: 외부 데이터베이스 연결 정보만 관리 (MySQL, PostgreSQL, Oracle, SQL Server, SQLite)
+- **기존 테이블**: `external_db_connections` - DB 연결 정보 저장
+- **기존 UI**: 단일 화면에서 DB 연결 목록 표시 및 CRUD 작업
+
+### 요구사항
+
+1. **탭 전환**: DB 연결 관리 ↔ REST API 연결 관리 간 탭 전환 UI
+2. **REST API 관리**: 요청 주소별 헤더(키-값 쌍) 관리
+3. **연결 테스트**: REST API 호출이 정상 작동하는지 테스트 기능
+
+---
+
+## 🗄️ 데이터베이스 설계
+
+### 신규 테이블: `external_rest_api_connections`
+
+```sql
+CREATE TABLE external_rest_api_connections (
+ id SERIAL PRIMARY KEY,
+
+ -- 기본 정보
+ connection_name VARCHAR(100) NOT NULL UNIQUE,
+ description TEXT,
+
+ -- REST API 연결 정보
+ base_url VARCHAR(500) NOT NULL, -- 기본 URL (예: https://api.example.com)
+ default_headers JSONB DEFAULT '{}', -- 기본 헤더 정보 (키-값 쌍)
+
+ -- 인증 설정
+ auth_type VARCHAR(20) DEFAULT 'none', -- none, api-key, bearer, basic, oauth2
+ auth_config JSONB, -- 인증 관련 설정
+
+ -- 고급 설정
+ timeout INTEGER DEFAULT 30000, -- 요청 타임아웃 (ms)
+ retry_count INTEGER DEFAULT 0, -- 재시도 횟수
+ retry_delay INTEGER DEFAULT 1000, -- 재시도 간격 (ms)
+
+ -- 관리 정보
+ company_code VARCHAR(20) DEFAULT '*',
+ is_active CHAR(1) DEFAULT 'Y',
+ created_date TIMESTAMP DEFAULT NOW(),
+ created_by VARCHAR(50),
+ updated_date TIMESTAMP DEFAULT NOW(),
+ updated_by VARCHAR(50),
+
+ -- 테스트 정보
+ last_test_date TIMESTAMP,
+ last_test_result CHAR(1), -- Y: 성공, N: 실패
+ last_test_message TEXT
+);
+
+-- 인덱스
+CREATE INDEX idx_rest_api_connections_company ON external_rest_api_connections(company_code);
+CREATE INDEX idx_rest_api_connections_active ON external_rest_api_connections(is_active);
+CREATE INDEX idx_rest_api_connections_name ON external_rest_api_connections(connection_name);
+```
+
+### 샘플 데이터
+
+```sql
+INSERT INTO external_rest_api_connections (
+ connection_name, description, base_url, default_headers, auth_type, auth_config
+) VALUES
+(
+ '기상청 API',
+ '기상청 공공데이터 API',
+ 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0',
+ '{"Content-Type": "application/json", "Accept": "application/json"}',
+ 'api-key',
+ '{"keyLocation": "query", "keyName": "serviceKey", "keyValue": "your-api-key-here"}'
+),
+(
+ '사내 인사 시스템 API',
+ '인사정보 조회용 내부 API',
+ 'https://hr.company.com/api/v1',
+ '{"Content-Type": "application/json"}',
+ 'bearer',
+ '{"token": "your-bearer-token-here"}'
+);
+```
+
+---
+
+## 🔧 백엔드 구현
+
+### 1. 타입 정의
+
+```typescript
+// backend-node/src/types/externalRestApiTypes.ts
+
+export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
+
+export interface ExternalRestApiConnection {
+ id?: number;
+ connection_name: string;
+ description?: string;
+ base_url: string;
+ default_headers: Record;
+ auth_type: AuthType;
+ auth_config?: {
+ // API Key
+ keyLocation?: "header" | "query";
+ keyName?: string;
+ keyValue?: string;
+
+ // Bearer Token
+ token?: string;
+
+ // Basic Auth
+ username?: string;
+ password?: string;
+
+ // OAuth2
+ clientId?: string;
+ clientSecret?: string;
+ tokenUrl?: string;
+ accessToken?: string;
+ };
+ timeout?: number;
+ retry_count?: number;
+ retry_delay?: number;
+ company_code: string;
+ is_active: string;
+ created_date?: Date;
+ created_by?: string;
+ updated_date?: Date;
+ updated_by?: string;
+ last_test_date?: Date;
+ last_test_result?: string;
+ last_test_message?: string;
+}
+
+export interface ExternalRestApiConnectionFilter {
+ auth_type?: string;
+ is_active?: string;
+ company_code?: string;
+ search?: string;
+}
+
+export interface RestApiTestRequest {
+ id?: number;
+ base_url: string;
+ endpoint?: string; // 테스트할 엔드포인트 (선택)
+ method?: "GET" | "POST" | "PUT" | "DELETE";
+ headers?: Record;
+ auth_type?: AuthType;
+ auth_config?: any;
+ timeout?: number;
+}
+
+export interface RestApiTestResult {
+ success: boolean;
+ message: string;
+ response_time?: number;
+ status_code?: number;
+ response_data?: any;
+ error_details?: string;
+}
+```
+
+### 2. 서비스 계층
+
+```typescript
+// backend-node/src/services/externalRestApiConnectionService.ts
+
+export class ExternalRestApiConnectionService {
+ // CRUD 메서드
+ static async getConnections(filter: ExternalRestApiConnectionFilter);
+ static async getConnectionById(id: number);
+ static async createConnection(data: ExternalRestApiConnection);
+ static async updateConnection(
+ id: number,
+ data: Partial
+ );
+ static async deleteConnection(id: number);
+
+ // 테스트 메서드
+ static async testConnection(
+ testRequest: RestApiTestRequest
+ ): Promise;
+ static async testConnectionById(
+ id: number,
+ endpoint?: string
+ ): Promise;
+
+ // 헬퍼 메서드
+ private static buildHeaders(
+ connection: ExternalRestApiConnection
+ ): Record;
+ private static validateConnectionData(data: ExternalRestApiConnection): void;
+ private static encryptSensitiveData(authConfig: any): any;
+ private static decryptSensitiveData(authConfig: any): any;
+}
+```
+
+### 3. API 라우트
+
+```typescript
+// backend-node/src/routes/externalRestApiConnectionRoutes.ts
+
+// GET /api/external-rest-api-connections - 목록 조회
+// GET /api/external-rest-api-connections/:id - 상세 조회
+// POST /api/external-rest-api-connections - 새 연결 생성
+// PUT /api/external-rest-api-connections/:id - 연결 수정
+// DELETE /api/external-rest-api-connections/:id - 연결 삭제
+// POST /api/external-rest-api-connections/test - 연결 테스트 (신규)
+// POST /api/external-rest-api-connections/:id/test - ID로 테스트 (기존 연결)
+```
+
+### 4. 연결 테스트 구현
+
+```typescript
+// REST API 연결 테스트 로직
+static async testConnection(testRequest: RestApiTestRequest): Promise {
+ const startTime = Date.now();
+
+ try {
+ // 헤더 구성
+ const headers = { ...testRequest.headers };
+
+ // 인증 헤더 추가
+ if (testRequest.auth_type === 'bearer' && testRequest.auth_config?.token) {
+ headers['Authorization'] = `Bearer ${testRequest.auth_config.token}`;
+ } else if (testRequest.auth_type === 'basic') {
+ const credentials = Buffer.from(
+ `${testRequest.auth_config.username}:${testRequest.auth_config.password}`
+ ).toString('base64');
+ headers['Authorization'] = `Basic ${credentials}`;
+ } else if (testRequest.auth_type === 'api-key') {
+ if (testRequest.auth_config.keyLocation === 'header') {
+ headers[testRequest.auth_config.keyName] = testRequest.auth_config.keyValue;
+ }
+ }
+
+ // URL 구성
+ let url = testRequest.base_url;
+ if (testRequest.endpoint) {
+ url = `${testRequest.base_url}${testRequest.endpoint}`;
+ }
+
+ // API Key가 쿼리에 있는 경우
+ if (testRequest.auth_type === 'api-key' &&
+ testRequest.auth_config.keyLocation === 'query') {
+ const separator = url.includes('?') ? '&' : '?';
+ url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`;
+ }
+
+ // HTTP 요청 실행
+ const response = await fetch(url, {
+ method: testRequest.method || 'GET',
+ headers,
+ signal: AbortSignal.timeout(testRequest.timeout || 30000),
+ });
+
+ const responseTime = Date.now() - startTime;
+ const responseData = await response.json().catch(() => null);
+
+ return {
+ success: response.ok,
+ message: response.ok ? '연결 성공' : `연결 실패 (${response.status})`,
+ response_time: responseTime,
+ status_code: response.status,
+ response_data: responseData,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ message: '연결 실패',
+ error_details: error instanceof Error ? error.message : '알 수 없는 오류',
+ };
+ }
+}
+```
+
+---
+
+## 🎨 프론트엔드 구현
+
+### 1. 탭 구조 설계
+
+```typescript
+// frontend/app/(main)/admin/external-connections/page.tsx
+
+type ConnectionTabType = "database" | "rest-api";
+
+const [activeTab, setActiveTab] = useState("database");
+```
+
+### 2. 메인 페이지 구조 개선
+
+```tsx
+// 탭 헤더
+ setActiveTab(value as ConnectionTabType)}
+>
+
+
+
+ 데이터베이스 연결
+
+
+
+ REST API 연결
+
+
+
+ {/* 데이터베이스 연결 탭 */}
+
+
+
+
+ {/* REST API 연결 탭 */}
+
+
+
+
+```
+
+### 3. REST API 연결 목록 컴포넌트
+
+```typescript
+// frontend/components/admin/RestApiConnectionList.tsx
+
+export function RestApiConnectionList() {
+ const [connections, setConnections] = useState(
+ []
+ );
+ const [searchTerm, setSearchTerm] = useState("");
+ const [authTypeFilter, setAuthTypeFilter] = useState("ALL");
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [editingConnection, setEditingConnection] = useState<
+ ExternalRestApiConnection | undefined
+ >();
+
+ // 테이블 컬럼:
+ // - 연결명
+ // - 기본 URL
+ // - 인증 타입
+ // - 헤더 수 (default_headers 개수)
+ // - 상태 (활성/비활성)
+ // - 마지막 테스트 (날짜 + 결과)
+ // - 작업 (테스트/편집/삭제)
+}
+```
+
+### 4. REST API 연결 설정 모달
+
+```typescript
+// frontend/components/admin/RestApiConnectionModal.tsx
+
+export function RestApiConnectionModal({
+ isOpen,
+ onClose,
+ onSave,
+ connection,
+}: RestApiConnectionModalProps) {
+ // 섹션 구성:
+ // 1. 기본 정보
+ // - 연결명 (필수)
+ // - 설명
+ // - 기본 URL (필수)
+ // 2. 헤더 관리 (키-값 추가/삭제)
+ // - 동적 입력 필드
+ // - + 버튼으로 추가
+ // - 각 행에 삭제 버튼
+ // 3. 인증 설정
+ // - 인증 타입 선택 (none/api-key/bearer/basic/oauth2)
+ // - 선택된 타입별 설정 필드 표시
+ // 4. 고급 설정 (접기/펼치기)
+ // - 타임아웃
+ // - 재시도 설정
+ // 5. 테스트 섹션
+ // - 테스트 엔드포인트 입력 (선택)
+ // - 테스트 실행 버튼
+ // - 테스트 결과 표시
+}
+```
+
+### 5. 헤더 관리 컴포넌트
+
+```typescript
+// frontend/components/admin/HeadersManager.tsx
+
+interface HeadersManagerProps {
+ headers: Record;
+ onChange: (headers: Record) => void;
+}
+
+export function HeadersManager({ headers, onChange }: HeadersManagerProps) {
+ const [headersList, setHeadersList] = useState<
+ Array<{ key: string; value: string }>
+ >(Object.entries(headers).map(([key, value]) => ({ key, value })));
+
+ const addHeader = () => {
+ setHeadersList([...headersList, { key: "", value: "" }]);
+ };
+
+ const removeHeader = (index: number) => {
+ const newList = headersList.filter((_, i) => i !== index);
+ setHeadersList(newList);
+ updateParent(newList);
+ };
+
+ const updateHeader = (
+ index: number,
+ field: "key" | "value",
+ value: string
+ ) => {
+ const newList = [...headersList];
+ newList[index][field] = value;
+ setHeadersList(newList);
+ updateParent(newList);
+ };
+
+ const updateParent = (list: Array<{ key: string; value: string }>) => {
+ const headersObject = list.reduce((acc, { key, value }) => {
+ if (key.trim()) acc[key] = value;
+ return acc;
+ }, {} as Record);
+ onChange(headersObject);
+ };
+
+ // UI: 테이블 형태로 키-값 입력 필드 표시
+ // 각 행: [키 입력] [값 입력] [삭제 버튼]
+ // 하단: [+ 헤더 추가] 버튼
+}
+```
+
+### 6. 인증 설정 컴포넌트
+
+```typescript
+// frontend/components/admin/AuthenticationConfig.tsx
+
+export function AuthenticationConfig({
+ authType,
+ authConfig,
+ onChange,
+}: AuthenticationConfigProps) {
+ // authType에 따라 다른 입력 필드 표시
+ // none: 추가 필드 없음
+ // api-key:
+ // - 키 위치 (header/query)
+ // - 키 이름
+ // - 키 값
+ // bearer:
+ // - 토큰 값
+ // basic:
+ // - 사용자명
+ // - 비밀번호
+ // oauth2:
+ // - Client ID
+ // - Client Secret
+ // - Token URL
+ // - Access Token (읽기전용, 자동 갱신)
+}
+```
+
+### 7. API 클라이언트
+
+```typescript
+// frontend/lib/api/externalRestApiConnection.ts
+
+export class ExternalRestApiConnectionAPI {
+ private static readonly BASE_URL = "/api/external-rest-api-connections";
+
+ static async getConnections(filter?: ExternalRestApiConnectionFilter) {
+ const params = new URLSearchParams();
+ if (filter?.search) params.append("search", filter.search);
+ if (filter?.auth_type && filter.auth_type !== "ALL") {
+ params.append("auth_type", filter.auth_type);
+ }
+ if (filter?.is_active && filter.is_active !== "ALL") {
+ params.append("is_active", filter.is_active);
+ }
+
+ const response = await fetch(`${this.BASE_URL}?${params}`);
+ return this.handleResponse(response);
+ }
+
+ static async getConnectionById(id: number) {
+ const response = await fetch(`${this.BASE_URL}/${id}`);
+ return this.handleResponse(response);
+ }
+
+ static async createConnection(data: ExternalRestApiConnection) {
+ const response = await fetch(this.BASE_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return this.handleResponse(response);
+ }
+
+ static async updateConnection(
+ id: number,
+ data: Partial
+ ) {
+ const response = await fetch(`${this.BASE_URL}/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return this.handleResponse(response);
+ }
+
+ static async deleteConnection(id: number) {
+ const response = await fetch(`${this.BASE_URL}/${id}`, {
+ method: "DELETE",
+ });
+ return this.handleResponse(response);
+ }
+
+ static async testConnection(
+ testRequest: RestApiTestRequest
+ ): Promise {
+ const response = await fetch(`${this.BASE_URL}/test`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(testRequest),
+ });
+ return this.handleResponse(response);
+ }
+
+ static async testConnectionById(
+ id: number,
+ endpoint?: string
+ ): Promise {
+ const response = await fetch(`${this.BASE_URL}/${id}/test`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ endpoint }),
+ });
+ return this.handleResponse(response);
+ }
+
+ private static async handleResponse(response: Response) {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.message || "요청 실패");
+ }
+ return response.json();
+ }
+}
+```
+
+---
+
+## 📋 구현 순서
+
+### Phase 1: 데이터베이스 및 백엔드 기본 구조 (1일)
+
+- [x] 데이터베이스 테이블 생성 (`external_rest_api_connections`)
+- [ ] 타입 정의 작성 (`externalRestApiTypes.ts`)
+- [ ] 서비스 계층 기본 CRUD 구현
+- [ ] API 라우트 기본 구현
+
+### Phase 2: 연결 테스트 기능 (1일)
+
+- [ ] 연결 테스트 로직 구현
+- [ ] 인증 타입별 헤더 구성 로직
+- [ ] 에러 처리 및 타임아웃 관리
+- [ ] 테스트 결과 저장 (last_test_date, last_test_result)
+
+### Phase 3: 프론트엔드 기본 UI (1-2일)
+
+- [ ] 탭 구조 추가 (Database / REST API)
+- [ ] REST API 연결 목록 컴포넌트
+- [ ] API 클라이언트 작성
+- [ ] 기본 CRUD UI 구현
+
+### Phase 4: 모달 및 상세 기능 (1-2일)
+
+- [ ] REST API 연결 설정 모달
+- [ ] 헤더 관리 컴포넌트 (키-값 동적 추가/삭제)
+- [ ] 인증 설정 컴포넌트 (타입별 입력 필드)
+- [ ] 고급 설정 섹션
+
+### Phase 5: 테스트 및 통합 (1일)
+
+- [ ] 연결 테스트 UI
+- [ ] 테스트 결과 표시
+- [ ] 에러 처리 및 사용자 피드백
+- [ ] 전체 기능 통합 테스트
+
+### Phase 6: 최적화 및 마무리 (0.5일)
+
+- [ ] 민감 정보 암호화 (API 키, 토큰, 비밀번호)
+- [ ] UI/UX 개선
+- [ ] 문서화
+
+---
+
+## 🧪 테스트 시나리오
+
+### 1. REST API 연결 등록 테스트
+
+- [ ] 기본 정보 입력 (연결명, URL)
+- [ ] 헤더 추가/삭제
+- [ ] 각 인증 타입별 설정
+- [ ] 유효성 검증 (필수 필드, URL 형식)
+
+### 2. 연결 테스트
+
+- [ ] 인증 없는 API 테스트
+- [ ] API Key (header/query) 테스트
+- [ ] Bearer Token 테스트
+- [ ] Basic Auth 테스트
+- [ ] 타임아웃 시나리오
+- [ ] 네트워크 오류 시나리오
+
+### 3. 데이터 관리
+
+- [ ] 목록 조회 및 필터링
+- [ ] 연결 수정
+- [ ] 연결 삭제
+- [ ] 활성/비활성 전환
+
+### 4. 통합 시나리오
+
+- [ ] DB 연결 탭 ↔ REST API 탭 전환
+- [ ] 여러 연결 등록 및 관리
+- [ ] 동시 테스트 실행
+
+---
+
+## 🔒 보안 고려사항
+
+### 1. 민감 정보 암호화
+
+```typescript
+// API 키, 토큰, 비밀번호 암호화
+private static encryptSensitiveData(authConfig: any): any {
+ if (!authConfig) return null;
+
+ const encrypted = { ...authConfig };
+
+ // 암호화 대상 필드
+ if (encrypted.keyValue) {
+ encrypted.keyValue = encrypt(encrypted.keyValue);
+ }
+ if (encrypted.token) {
+ encrypted.token = encrypt(encrypted.token);
+ }
+ if (encrypted.password) {
+ encrypted.password = encrypt(encrypted.password);
+ }
+ if (encrypted.clientSecret) {
+ encrypted.clientSecret = encrypt(encrypted.clientSecret);
+ }
+
+ return encrypted;
+}
+```
+
+### 2. 접근 권한 제어
+
+- 관리자 권한만 접근
+- 회사별 데이터 분리
+- API 호출 시 인증 토큰 검증
+
+### 3. 테스트 요청 제한
+
+- Rate Limiting (1분에 최대 10회)
+- 타임아웃 설정 (최대 30초)
+- 동시 테스트 제한
+
+---
+
+## 📊 성능 최적화
+
+### 1. 헤더 데이터 구조
+
+```typescript
+// JSONB 필드 인덱싱 (PostgreSQL)
+CREATE INDEX idx_rest_api_headers ON external_rest_api_connections
+USING GIN (default_headers);
+
+CREATE INDEX idx_rest_api_auth_config ON external_rest_api_connections
+USING GIN (auth_config);
+```
+
+### 2. 캐싱 전략
+
+- 자주 사용되는 연결 정보 캐싱
+- 테스트 결과 임시 캐싱 (5분)
+
+---
+
+## 📚 향후 확장 가능성
+
+### 1. 엔드포인트 관리
+
+각 REST API 연결에 대해 자주 사용하는 엔드포인트를 사전 등록하여 빠른 호출 가능
+
+### 2. 요청 템플릿
+
+HTTP 메서드별 요청 바디 템플릿 관리
+
+### 3. 응답 매핑
+
+REST API 응답을 내부 데이터 구조로 변환하는 매핑 룰 설정
+
+### 4. 로그 및 모니터링
+
+- API 호출 이력 기록
+- 응답 시간 모니터링
+- 오류율 추적
+
+---
+
+## ✅ 완료 체크리스트
+
+### 백엔드
+
+- [ ] 데이터베이스 테이블 생성
+- [ ] 타입 정의
+- [ ] 서비스 계층 CRUD
+- [ ] 연결 테스트 로직
+- [ ] API 라우트
+- [ ] 민감 정보 암호화
+
+### 프론트엔드
+
+- [ ] 탭 구조
+- [ ] REST API 연결 목록
+- [ ] 연결 설정 모달
+- [ ] 헤더 관리 컴포넌트
+- [ ] 인증 설정 컴포넌트
+- [ ] API 클라이언트
+- [ ] 연결 테스트 UI
+
+### 테스트
+
+- [ ] 단위 테스트
+- [ ] 통합 테스트
+- [ ] 사용자 시나리오 테스트
+
+### 문서
+
+- [ ] API 문서
+- [ ] 사용자 가이드
+- [ ] 배포 가이드
+
+---
+
+**작성일**: 2025-10-20
+**버전**: 1.0
+**담당**: AI Assistant
diff --git a/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md
new file mode 100644
index 00000000..051ca3d4
--- /dev/null
+++ b/PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT_DONE.md
@@ -0,0 +1,213 @@
+# REST API 연결 관리 기능 구현 완료
+
+## 구현 개요
+
+외부 커넥션 관리 페이지(`/admin/external-connections`)에 REST API 연결 관리 기능이 추가되었습니다.
+기존의 데이터베이스 연결 관리와 함께 REST API 연결도 관리할 수 있도록 탭 기반 UI가 구현되었습니다.
+
+## 구현 완료 사항
+
+### 1. 데이터베이스 (✅ 완료)
+
+**파일**: `/db/create_external_rest_api_connections.sql`
+
+- `external_rest_api_connections` 테이블 생성
+- 연결 정보, 인증 설정, 테스트 결과 저장
+- JSONB 타입으로 헤더 및 인증 설정 유연하게 관리
+- 인덱스 최적화 (company_code, is_active, auth_type, JSONB GIN 인덱스)
+
+**실행 방법**:
+
+```bash
+# PostgreSQL 컨테이너에 접속하여 SQL 실행
+docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql
+```
+
+### 2. 백엔드 구현 (✅ 완료)
+
+#### 2.1 타입 정의
+
+**파일**: `backend-node/src/types/externalRestApiTypes.ts`
+
+- `ExternalRestApiConnection`: REST API 연결 정보 인터페이스
+- `RestApiTestRequest`: 연결 테스트 요청 인터페이스
+- `RestApiTestResult`: 테스트 결과 인터페이스
+- `AuthType`: 인증 타입 (none, api-key, bearer, basic, oauth2)
+- 각 인증 타입별 세부 설정 인터페이스
+
+#### 2.2 서비스 레이어
+
+**파일**: `backend-node/src/services/externalRestApiConnectionService.ts`
+
+- CRUD 작업 구현 (생성, 조회, 수정, 삭제)
+- 민감 정보 암호화/복호화 (AES-256-GCM)
+- REST API 연결 테스트 기능
+- 필터링 및 검색 기능
+- 유효성 검증
+
+#### 2.3 API 라우트
+
+**파일**: `backend-node/src/routes/externalRestApiConnectionRoutes.ts`
+
+- `GET /api/external-rest-api-connections` - 목록 조회
+- `GET /api/external-rest-api-connections/:id` - 상세 조회
+- `POST /api/external-rest-api-connections` - 생성
+- `PUT /api/external-rest-api-connections/:id` - 수정
+- `DELETE /api/external-rest-api-connections/:id` - 삭제
+- `POST /api/external-rest-api-connections/test` - 연결 테스트
+- `POST /api/external-rest-api-connections/:id/test` - ID 기반 테스트
+
+#### 2.4 앱 통합
+
+**파일**: `backend-node/src/app.ts`
+
+- 새로운 라우트 등록 완료
+
+### 3. 프론트엔드 구현 (✅ 완료)
+
+#### 3.1 API 클라이언트
+
+**파일**: `frontend/lib/api/externalRestApiConnection.ts`
+
+- 백엔드 API와 통신하는 클라이언트 구현
+- 타입 안전한 API 호출
+- 에러 처리
+
+#### 3.2 공통 컴포넌트
+
+**파일**: `frontend/components/admin/HeadersManager.tsx`
+
+- HTTP 헤더 key-value 관리 컴포넌트
+- 동적 추가/삭제 기능
+
+**파일**: `frontend/components/admin/AuthenticationConfig.tsx`
+
+- 인증 타입별 설정 컴포넌트
+- 5가지 인증 방식 지원 (none, api-key, bearer, basic, oauth2)
+
+#### 3.3 모달 컴포넌트
+
+**파일**: `frontend/components/admin/RestApiConnectionModal.tsx`
+
+- 연결 추가/수정 모달
+- 헤더 관리 및 인증 설정 통합
+- 연결 테스트 기능
+
+#### 3.4 목록 관리 컴포넌트
+
+**파일**: `frontend/components/admin/RestApiConnectionList.tsx`
+
+- REST API 연결 목록 표시
+- 검색 및 필터링
+- CRUD 작업
+- 연결 테스트
+
+#### 3.5 메인 페이지
+
+**파일**: `frontend/app/(main)/admin/external-connections/page.tsx`
+
+- 탭 기반 UI 구현 (데이터베이스 ↔ REST API)
+- 기존 DB 연결 관리와 통합
+
+## 주요 기능
+
+### 1. 연결 관리
+
+- REST API 연결 정보 생성/수정/삭제
+- 연결명, 설명, Base URL 관리
+- Timeout, Retry 설정
+- 활성화 상태 관리
+
+### 2. 인증 관리
+
+- **None**: 인증 없음
+- **API Key**: 헤더 또는 쿼리 파라미터
+- **Bearer Token**: Authorization: Bearer {token}
+- **Basic Auth**: username/password
+- **OAuth2**: client_id, client_secret, token_url 등
+
+### 3. 헤더 관리
+
+- 기본 HTTP 헤더 설정
+- Key-Value 형식으로 동적 관리
+- Content-Type, Accept 등 자유롭게 설정
+
+### 4. 연결 테스트
+
+- 실시간 연결 테스트
+- HTTP 응답 상태 코드 확인
+- 응답 시간 측정
+- 테스트 결과 저장
+
+### 5. 보안
+
+- 민감 정보 자동 암호화 (AES-256-GCM)
+ - API Key
+ - Bearer Token
+ - 비밀번호
+ - OAuth2 Client Secret
+- 암호화된 데이터는 데이터베이스에 안전하게 저장
+
+## 사용 방법
+
+### 1. SQL 스크립트 실행
+
+```bash
+# PostgreSQL 컨테이너에 접속
+docker exec -it esgrin-mes-db psql -U postgres -d ilshin
+
+# 또는 파일 직접 실행
+docker exec -i esgrin-mes-db psql -U postgres -d ilshin < db/create_external_rest_api_connections.sql
+```
+
+### 2. 백엔드 재시작
+
+백엔드 서버가 자동으로 새로운 라우트를 인식합니다. (이미 재시작 완료)
+
+### 3. 웹 UI 접속
+
+1. `/admin/external-connections` 페이지 접속
+2. "REST API 연결" 탭 선택
+3. "새 연결 추가" 버튼 클릭
+4. 필요한 정보 입력
+ - 연결명, 설명, Base URL
+ - 기본 헤더 설정
+ - 인증 타입 선택 및 인증 정보 입력
+ - Timeout, Retry 설정
+5. "연결 테스트" 버튼으로 즉시 테스트 가능
+6. 저장
+
+### 4. 연결 관리
+
+- **목록 조회**: 모든 REST API 연결 정보 확인
+- **검색**: 연결명, 설명, URL로 검색
+- **필터링**: 인증 타입, 활성화 상태로 필터링
+- **수정**: 연필 아이콘 클릭하여 수정
+- **삭제**: 휴지통 아이콘 클릭하여 삭제
+- **테스트**: Play 아이콘 클릭하여 연결 테스트
+
+## 기술 스택
+
+- **Backend**: Node.js, Express, TypeScript, PostgreSQL
+- **Frontend**: Next.js, React, TypeScript, Shadcn UI
+- **보안**: AES-256-GCM 암호화
+- **데이터**: JSONB (PostgreSQL)
+
+## 테스트 완료
+
+- ✅ 백엔드 컴파일 성공
+- ✅ 서버 정상 실행 확인
+- ✅ 타입 에러 수정 완료
+- ✅ 모든 라우트 등록 완료
+- ✅ 인증 토큰 자동 포함 구현 (apiClient 사용)
+
+## 다음 단계
+
+1. SQL 스크립트 실행
+2. 프론트엔드 빌드 및 테스트
+3. UI에서 연결 추가/수정/삭제/테스트 기능 확인
+
+## 참고 문서
+
+- 전체 계획: `PHASE_EXTERNAL_CONNECTION_REST_API_ENHANCEMENT.md`
+- 기존 외부 DB 연결: `제어관리_외부커넥션_통합_기능_가이드.md`
diff --git a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json
new file mode 100644
index 00000000..9e7a209c
--- /dev/null
+++ b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json
@@ -0,0 +1,19 @@
+{
+ "id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e",
+ "sentAt": "2025-10-22T05:17:38.303Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "Fwd: ㅏㅣ",
+ "htmlContent": "\r\n \r\n
ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄
\r\n
\r\n \r\n \r\n
---------- 전달된 메시지 ----------
\r\n
보낸 사람: \"이희진\"
\r\n
날짜: 2025. 10. 22. 오후 1:32:34
\r\n
제목: ㅏㅣ
\r\n
\r\n undefined\r\n
\r\n ",
+ "status": "success",
+ "messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": [],
+ "deletedAt": "2025-10-22T06:36:10.876Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json
new file mode 100644
index 00000000..2f624e9c
--- /dev/null
+++ b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json
@@ -0,0 +1,16 @@
+{
+ "id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트",
+ "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n",
+ "sentAt": "2025-10-22T07:49:50.811Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:49:50.811Z",
+ "deletedAt": "2025-10-22T07:50:14.211Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json
new file mode 100644
index 00000000..683ad20c
--- /dev/null
+++ b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json
@@ -0,0 +1,18 @@
+{
+ "id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
+ "sentAt": "2025-10-22T07:13:30.905Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "Fwd: ㄴ",
+ "htmlContent": "\r\n \r\n
전달히야야양
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 전달된 메일:
보낸사람: \"이희진\" 날짜: 2025. 10. 22. 오후 12:58:15 제목: ㄴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
ㄴㅇㄹㄴㅇㄹㄴㅇㄹ
\r\n
\r\n ",
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json
new file mode 100644
index 00000000..5090fdd2
--- /dev/null
+++ b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json
@@ -0,0 +1,18 @@
+{
+ "id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
+ "sentAt": "2025-10-22T07:18:18.240Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
+ "htmlContent": "\r\n \r\n ",
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json
new file mode 100644
index 00000000..c142808d
--- /dev/null
+++ b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json
@@ -0,0 +1,19 @@
+{
+ "id": "375f2326-ca86-468a-bfc3-2d4c3825577b",
+ "sentAt": "2025-10-22T04:57:39.706Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"이희진\" "
+ ],
+ "subject": "Re: ㅏㅣ",
+ "htmlContent": "\r\n \r\n
ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ
\r\n
\r\n \r\n \r\n
보낸 사람: \"이희진\"
\r\n
날짜: 2025. 10. 22. 오후 1:32:34
\r\n
제목: ㅏㅣ
\r\n
\r\n undefined\r\n
\r\n ",
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": [],
+ "deletedAt": "2025-10-22T07:11:04.666Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json
new file mode 100644
index 00000000..31da5552
--- /dev/null
+++ b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json
@@ -0,0 +1,16 @@
+{
+ "id": "386e334a-df76-440c-ae8a-9bf06982fdc8",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㄴ",
+ "htmlContent": "\n \n \n
---------- 전달된 메일 ----------
\n
보낸사람: \"이희진\" <zian9227@naver.com>
\n
날짜: 2025. 10. 22. 오후 12:58:15
\n
제목: ㄴ
\n
\n
ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n
\n
\n ",
+ "sentAt": "2025-10-22T07:04:27.192Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:04:57.280Z",
+ "deletedAt": "2025-10-22T07:50:17.136Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json
new file mode 100644
index 00000000..aa107de7
--- /dev/null
+++ b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json
@@ -0,0 +1,18 @@
+{
+ "id": "3d411dc4-69a6-4236-b878-9693dff881be",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "cc": [],
+ "bcc": [],
+ "subject": "Re: ㄴ",
+ "htmlContent": "\n \n \n
원본 메일:
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 12:58:15
\n
제목: ㄴ
\n
\n
undefined
\n
\n ",
+ "sentAt": "2025-10-22T06:56:51.060Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:56:51.060Z",
+ "deletedAt": "2025-10-22T07:50:22.989Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json
new file mode 100644
index 00000000..d824d67b
--- /dev/null
+++ b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json
@@ -0,0 +1,16 @@
+{
+ "id": "3e30a264-8431-44c7-96ef-eed551e66a11",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㄴ",
+ "htmlContent": "\n \n \n
---------- 전달된 메일 ----------
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 12:58:15
\n
제목: ㄴ
\n
\n
\n
\n ",
+ "sentAt": "2025-10-22T06:57:53.335Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:00:23.394Z",
+ "deletedAt": "2025-10-22T07:50:20.510Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json
new file mode 100644
index 00000000..92de4a0c
--- /dev/null
+++ b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json
@@ -0,0 +1,16 @@
+{
+ "id": "4a32bab5-364e-4037-bb00-31d2905824db",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "테스트 마지가",
+ "htmlContent": "ㅁㄴㅇㄹ",
+ "sentAt": "2025-10-22T07:49:29.948Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:49:29.948Z",
+ "deletedAt": "2025-10-22T07:50:12.374Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json
new file mode 100644
index 00000000..5f5a5cfc
--- /dev/null
+++ b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json
@@ -0,0 +1,16 @@
+{
+ "id": "5bfb2acd-023a-4865-a738-2900179db5fb",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㄴ",
+ "htmlContent": "\n \n \n
---------- 전달된 메일 ----------
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 12:58:15
\n
제목: ㄴ
\n
\n
ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n
\n
\n ",
+ "sentAt": "2025-10-22T07:03:09.080Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:03:39.150Z",
+ "deletedAt": "2025-10-22T07:50:19.035Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json
new file mode 100644
index 00000000..b3c3259f
--- /dev/null
+++ b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json
@@ -0,0 +1,18 @@
+{
+ "id": "683c1323-1895-403a-bb9a-4e111a8909f6",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "cc": [],
+ "bcc": [],
+ "subject": "Re: ㄴ",
+ "htmlContent": "\n \n \n
원본 메일:
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 12:58:15
\n
제목: ㄴ
\n
\n
undefined
\n
\n ",
+ "sentAt": "2025-10-22T06:54:55.097Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:54:55.097Z",
+ "deletedAt": "2025-10-22T07:50:24.672Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json
new file mode 100644
index 00000000..d9edbdeb
--- /dev/null
+++ b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json
@@ -0,0 +1,16 @@
+{
+ "id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㅏㅣ",
+ "htmlContent": "\n \n \n
---------- 전달된 메일 ----------
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 1:32:34
\n
제목: ㅏㅣ
\n
\n undefined\n
\n ",
+ "sentAt": "2025-10-22T06:41:52.984Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:46:23.051Z",
+ "deletedAt": "2025-10-22T07:50:29.124Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json
new file mode 100644
index 00000000..37317a6a
--- /dev/null
+++ b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json
@@ -0,0 +1,18 @@
+{
+ "id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
+ "sentAt": "2025-10-22T04:27:51.044Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"이희진\" "
+ ],
+ "subject": "Re: ㅅㄷㄴㅅ",
+ "htmlContent": "\r\n \r\n \r\n \r\n
보낸 사람: \"이희진\"
\r\n
날짜: 2025. 10. 22. 오후 1:03:03
\r\n
제목: ㅅㄷㄴㅅ
\r\n
\r\n undefined\r\n
\r\n ",
+ "status": "success",
+ "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json
new file mode 100644
index 00000000..f0ed2dcf
--- /dev/null
+++ b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json
@@ -0,0 +1,13 @@
+{
+ "id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0",
+ "accountName": "",
+ "accountEmail": "",
+ "to": [],
+ "subject": "",
+ "htmlContent": "",
+ "sentAt": "2025-10-22T06:17:31.379Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:17:31.379Z",
+ "deletedAt": "2025-10-22T07:50:30.736Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json
new file mode 100644
index 00000000..4ac647c7
--- /dev/null
+++ b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json
@@ -0,0 +1,18 @@
+{
+ "id": "89a32ace-f39b-44fa-b614-c65d96548f92",
+ "sentAt": "2025-10-22T03:49:48.461Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "Fwd: 기상청 API허브 회원가입 인증번호",
+ "htmlContent": "\r\n \r\n
---------- 전달된 메시지 ----------
보낸 사람: \"기상청 API허브\"
날짜: 2025. 10. 13. 오후 4:26:45
제목: 기상청 API허브 회원가입 인증번호
undefined
\r\n
\r\n ",
+ "status": "success",
+ "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json
new file mode 100644
index 00000000..1c6dc41f
--- /dev/null
+++ b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json
@@ -0,0 +1,13 @@
+{
+ "id": "99703f2c-740c-492e-a866-a04289a9b699",
+ "accountName": "",
+ "accountEmail": "",
+ "to": [],
+ "subject": "",
+ "htmlContent": "",
+ "sentAt": "2025-10-22T06:20:08.450Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:20:08.450Z",
+ "deletedAt": "2025-10-22T06:36:07.797Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json
new file mode 100644
index 00000000..31bde67a
--- /dev/null
+++ b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json
@@ -0,0 +1,19 @@
+{
+ "id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e",
+ "sentAt": "2025-10-22T04:31:17.175Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"이희진\" "
+ ],
+ "subject": "Re: ㅅㄷㄴㅅ",
+ "htmlContent": "\r\n \r\n
배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아
\r\n
\r\n \r\n \r\n
보낸 사람: \"이희진\"
\r\n
날짜: 2025. 10. 22. 오후 1:03:03
\r\n
제목: ㅅㄷㄴㅅ
\r\n
\r\n undefined\r\n
\r\n ",
+ "status": "success",
+ "messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": [],
+ "deletedAt": "2025-10-22T07:11:10.245Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json
new file mode 100644
index 00000000..2ace7d67
--- /dev/null
+++ b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json
@@ -0,0 +1,18 @@
+{
+ "id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "cc": [],
+ "bcc": [],
+ "subject": "Re: ㅏㅣ",
+ "htmlContent": "\n \n \n
원본 메일:
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 22. 오후 1:32:34
\n
제목: ㅏㅣ
\n
\n
undefined
\n
\n ",
+ "sentAt": "2025-10-22T06:50:04.224Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:50:04.224Z",
+ "deletedAt": "2025-10-22T07:50:26.224Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json
new file mode 100644
index 00000000..5cf165c3
--- /dev/null
+++ b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json
@@ -0,0 +1,18 @@
+{
+ "id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
+ "sentAt": "2025-10-22T07:21:13.723Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
+ "htmlContent": "\r\n \r\n ",
+ "status": "success",
+ "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json
new file mode 100644
index 00000000..77d9053f
--- /dev/null
+++ b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json
@@ -0,0 +1,18 @@
+{
+ "id": "b293e530-2b2d-4b8a-8081-d103fab5a13f",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "cc": [],
+ "bcc": [],
+ "subject": "Re: 수신메일확인용",
+ "htmlContent": "\n \n \n
원본 메일:
\n
보낸사람: \"이희진\"
\n
날짜: 2025. 10. 13. 오전 10:40:30
\n
제목: 수신메일확인용
\n
\n undefined\n
\n ",
+ "sentAt": "2025-10-22T06:47:53.815Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:48:53.876Z",
+ "deletedAt": "2025-10-22T07:50:27.706Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json
new file mode 100644
index 00000000..426f81fb
--- /dev/null
+++ b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json
@@ -0,0 +1,16 @@
+{
+ "id": "cf892a77-1998-4165-bb9d-b390451465b2",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "Fwd: ㄴ",
+ "htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" \n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n",
+ "sentAt": "2025-10-22T07:06:11.620Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T07:07:11.749Z",
+ "deletedAt": "2025-10-22T07:50:15.739Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json
new file mode 100644
index 00000000..cf31f7dc
--- /dev/null
+++ b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json
@@ -0,0 +1,13 @@
+{
+ "id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8",
+ "accountName": "",
+ "accountEmail": "",
+ "to": [],
+ "subject": "",
+ "htmlContent": "",
+ "sentAt": "2025-10-22T06:15:02.128Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:15:02.128Z",
+ "deletedAt": "2025-10-22T07:08:43.543Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json
new file mode 100644
index 00000000..74c8212f
--- /dev/null
+++ b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json
@@ -0,0 +1,27 @@
+{
+ "id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
+ "sentAt": "2025-10-22T04:28:42.686Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"권은아\" "
+ ],
+ "subject": "Re: 매우 졸린 오후예요",
+ "htmlContent": "\r\n \r\n
호홋 답장 기능을 구현했다죵 얼른 퇴근하고 싪네여
\r\n
\r\n \r\n \r\n
보낸 사람: \"권은아\"
\r\n
날짜: 2025. 10. 22. 오후 1:10:37
\r\n
제목: 매우 졸린 오후예요
\r\n
\r\n undefined\r\n
\r\n ",
+ "attachments": [
+ {
+ "filename": "test용 이미지2.png",
+ "originalName": "test용 이미지2.png",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
+ "mimetype": "image/png"
+ }
+ ],
+ "status": "success",
+ "messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
+ "accepted": [
+ "chna8137s@gmail.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json
new file mode 100644
index 00000000..0c19dc0c
--- /dev/null
+++ b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json
@@ -0,0 +1,16 @@
+{
+ "id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [],
+ "cc": [],
+ "bcc": [],
+ "subject": "메일 임시저장 테스트 4",
+ "htmlContent": "asd",
+ "sentAt": "2025-10-22T06:21:40.019Z",
+ "status": "draft",
+ "isDraft": true,
+ "updatedAt": "2025-10-22T06:21:40.019Z",
+ "deletedAt": "2025-10-22T06:36:05.306Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json
new file mode 100644
index 00000000..efd9a0c0
--- /dev/null
+++ b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json
@@ -0,0 +1,18 @@
+{
+ "id": "fcea6149-a098-4212-aa00-baef0cc083d6",
+ "sentAt": "2025-10-22T04:24:54.126Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"DHS\" "
+ ],
+ "subject": "Re: 안녕하세여",
+ "htmlContent": "\r\n \r\n
어떻게 가는지 궁금한데 이따가 화면 보여주세영
\r\n
\r\n \r\n \r\n
보낸 사람: \"DHS\"
\r\n
날짜: 2025. 10. 22. 오후 1:09:49
\r\n
제목: 안녕하세여
\r\n
\r\n undefined\r\n
\r\n ",
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "ddhhss0603@gmail.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json
new file mode 100644
index 00000000..073c20f0
--- /dev/null
+++ b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json
@@ -0,0 +1,28 @@
+{
+ "id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082",
+ "sentAt": "2025-10-22T04:29:14.738Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "\"이희진\" "
+ ],
+ "subject": "Re: ㅅㄷㄴㅅ",
+ "htmlContent": "\r\n \r\n
ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ
\r\n
\r\n \r\n \r\n
보낸 사람: \"이희진\"
\r\n
날짜: 2025. 10. 22. 오후 1:03:03
\r\n
제목: ㅅㄷㄴㅅ
\r\n
\r\n undefined\r\n
\r\n ",
+ "attachments": [
+ {
+ "filename": "test용 이미지2.png",
+ "originalName": "test용 이미지2.png",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1761107350246-298369766.png",
+ "mimetype": "image/png"
+ }
+ ],
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": [],
+ "deletedAt": "2025-10-22T07:11:12.907Z"
+}
\ No newline at end of file
diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json
index e10d42af..5274d604 100644
--- a/backend-node/data/todos/todos.json
+++ b/backend-node/data/todos/todos.json
@@ -1,55 +1,80 @@
[
- {
- "id": "e5bb334c-d58a-4068-ad77-2607a41f4675",
- "title": "ㅁㄴㅇㄹ",
- "description": "ㅁㄴㅇㄹ",
- "priority": "normal",
- "status": "completed",
- "assignedTo": "",
- "dueDate": "2025-10-20T18:17",
- "createdAt": "2025-10-20T06:15:49.610Z",
- "updatedAt": "2025-10-20T07:36:06.370Z",
- "isUrgent": false,
- "order": 0,
- "completedAt": "2025-10-20T07:36:06.370Z"
- },
- {
- "id": "334be17c-7776-47e8-89ec-4b57c4a34bcd",
- "title": "연동되어주겠니?",
- "description": "",
- "priority": "normal",
- "status": "pending",
- "assignedTo": "",
- "dueDate": "",
- "createdAt": "2025-10-20T06:20:06.343Z",
- "updatedAt": "2025-10-20T06:20:06.343Z",
- "isUrgent": false,
- "order": 1
- },
- {
- "id": "f85b81de-fcbd-4858-8973-247d9d6e70ed",
- "title": "연동되어주겠니?11",
- "description": "ㄴㅇㄹ",
- "priority": "normal",
- "status": "pending",
- "assignedTo": "",
- "dueDate": "2025-10-20T17:22",
- "createdAt": "2025-10-20T06:20:53.818Z",
- "updatedAt": "2025-10-20T06:20:53.818Z",
- "isUrgent": false,
- "order": 2
- },
{
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
"title": "연동되어주려무니",
"description": "ㅁㄴㅇㄹ",
"priority": "normal",
- "status": "pending",
+ "status": "in_progress",
"assignedTo": "",
"dueDate": "2025-10-21T15:21",
"createdAt": "2025-10-20T06:21:19.817Z",
- "updatedAt": "2025-10-20T06:21:19.817Z",
+ "updatedAt": "2025-10-20T09:00:26.948Z",
"isUrgent": false,
"order": 3
+ },
+ {
+ "id": "c8292b4d-bb45-487c-aa29-55b78580b837",
+ "title": "오늘의 힐일",
+ "description": "이거 데이터베이스랑 연결하기",
+ "priority": "normal",
+ "status": "pending",
+ "assignedTo": "",
+ "dueDate": "2025-10-23T14:04",
+ "createdAt": "2025-10-23T05:04:50.249Z",
+ "updatedAt": "2025-10-23T05:04:50.249Z",
+ "isUrgent": false,
+ "order": 4
+ },
+ {
+ "id": "2c7f90a3-947c-4693-8525-7a2a707172c0",
+ "title": "테스트용 일정",
+ "description": "ㅁㄴㅇㄹ",
+ "priority": "low",
+ "status": "pending",
+ "assignedTo": "",
+ "dueDate": "2025-10-16T18:16",
+ "createdAt": "2025-10-23T05:13:14.076Z",
+ "updatedAt": "2025-10-23T05:13:14.076Z",
+ "isUrgent": false,
+ "order": 5
+ },
+ {
+ "id": "499feff6-92c7-45a9-91fa-ca727edf90f2",
+ "title": "ㅁSdf",
+ "description": "asdfsdfs",
+ "priority": "normal",
+ "status": "pending",
+ "assignedTo": "",
+ "dueDate": "",
+ "createdAt": "2025-10-23T05:15:38.430Z",
+ "updatedAt": "2025-10-23T05:15:38.430Z",
+ "isUrgent": false,
+ "order": 6
+ },
+ {
+ "id": "166c3910-9908-457f-8c72-8d0183f12e2f",
+ "title": "ㅎㄹㅇㄴ",
+ "description": "ㅎㄹㅇㄴ",
+ "priority": "normal",
+ "status": "pending",
+ "assignedTo": "",
+ "dueDate": "",
+ "createdAt": "2025-10-23T05:21:01.515Z",
+ "updatedAt": "2025-10-23T05:21:01.515Z",
+ "isUrgent": false,
+ "order": 7
+ },
+ {
+ "id": "bfa9d476-bb98-41d5-9d74-b016be011bba",
+ "title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ",
+ "description": "ㅁㄴㅇㄹㄴㅇㄹ",
+ "priority": "normal",
+ "status": "pending",
+ "assignedTo": "",
+ "dueDate": "",
+ "createdAt": "2025-10-23T05:21:25.781Z",
+ "updatedAt": "2025-10-23T05:21:25.781Z",
+ "isUrgent": false,
+ "order": 8
}
]
\ No newline at end of file
diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json
index 46d2fea5..81adfc5c 100644
--- a/backend-node/package-lock.json
+++ b/backend-node/package-lock.json
@@ -31,6 +31,8 @@
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
+ "quill": "^2.0.3",
+ "react-quill": "^2.0.0",
"redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0"
@@ -3433,6 +3435,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/quill": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
+ "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
+ "license": "MIT",
+ "dependencies": {
+ "parchment": "^1.1.2"
+ }
+ },
+ "node_modules/@types/quill/node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
@@ -4437,6 +4454,24 @@
"node": ">= 0.8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4610,6 +4645,15 @@
"node": ">=12"
}
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -4944,6 +4988,26 @@
}
}
},
+ "node_modules/deep-equal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arguments": "^1.1.1",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4988,6 +5052,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -5000,6 +5081,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -5554,6 +5652,12 @@
"node": ">=6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -5689,6 +5793,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5696,6 +5806,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "license": "Apache-2.0"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -5997,6 +6113,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -6249,6 +6374,18 @@
"node": ">=8"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -6563,6 +6700,22 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -6599,6 +6752,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
@@ -6701,6 +6870,24 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -7658,6 +7845,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -7670,6 +7875,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -8292,6 +8504,31 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -8436,6 +8673,12 @@
"node": ">=6"
}
},
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -8960,6 +9203,35 @@
],
"license": "MIT"
},
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -9003,6 +9275,67 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-quill": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
+ "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/quill": "^1.3.10",
+ "lodash": "^4.17.4",
+ "quill": "^1.3.7"
+ },
+ "peerDependencies": {
+ "react": "^16 || ^17 || ^18",
+ "react-dom": "^16 || ^17 || ^18"
+ }
+ },
+ "node_modules/react-quill/node_modules/eventemitter3": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
+ "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
+ "license": "MIT"
+ },
+ "node_modules/react-quill/node_modules/fast-diff": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
+ "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/react-quill/node_modules/parchment": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/react-quill/node_modules/quill": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
+ "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "clone": "^2.1.1",
+ "deep-equal": "^1.0.1",
+ "eventemitter3": "^2.0.3",
+ "extend": "^3.0.2",
+ "parchment": "^1.1.4",
+ "quill-delta": "^3.6.2"
+ }
+ },
+ "node_modules/react-quill/node_modules/quill-delta": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
+ "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-equal": "^1.0.1",
+ "extend": "^3.0.2",
+ "fast-diff": "1.1.2"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -9054,6 +9387,26 @@
"@redis/time-series": "1.1.0"
}
},
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -9325,6 +9678,38 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
diff --git a/backend-node/package.json b/backend-node/package.json
index a6744ac6..bacd9fb3 100644
--- a/backend-node/package.json
+++ b/backend-node/package.json
@@ -45,6 +45,8 @@
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
+ "quill": "^2.0.3",
+ "react-quill": "^2.0.0",
"redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0"
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index c503f548..979d191b 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -31,10 +31,12 @@ import layoutRoutes from "./routes/layoutRoutes";
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
+import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
+import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import screenFileRoutes from "./routes/screenFileRoutes";
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
@@ -185,11 +187,13 @@ app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
+app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
+app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes);
@@ -268,6 +272,28 @@ app.listen(PORT, HOST, async () => {
} catch (error) {
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
}
+
+ // 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
+ try {
+ const cron = await import("node-cron");
+ const { mailSentHistoryService } = await import(
+ "./services/mailSentHistoryService"
+ );
+
+ cron.schedule("0 2 * * *", async () => {
+ try {
+ logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
+ const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails();
+ logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
+ } catch (error) {
+ logger.error("❌ 메일 자동 삭제 실패:", error);
+ }
+ });
+
+ logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
+ } catch (error) {
+ logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
+ }
});
export default app;
diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts
index 7d710110..48df8c8f 100644
--- a/backend-node/src/controllers/DashboardController.ts
+++ b/backend-node/src/controllers/DashboardController.ts
@@ -24,6 +24,8 @@ export class DashboardController {
): Promise {
try {
const userId = req.user?.userId;
+ const companyCode = req.user?.companyCode;
+
if (!userId) {
res.status(401).json({
success: false,
@@ -89,7 +91,8 @@ export class DashboardController {
const savedDashboard = await DashboardService.createDashboard(
dashboardData,
- userId
+ userId,
+ companyCode
);
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
@@ -121,6 +124,7 @@ export class DashboardController {
async getDashboards(req: AuthenticatedRequest, res: Response): Promise {
try {
const userId = req.user?.userId;
+ const companyCode = req.user?.companyCode;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
@@ -145,7 +149,11 @@ export class DashboardController {
return;
}
- const result = await DashboardService.getDashboards(query, userId);
+ const result = await DashboardService.getDashboards(
+ query,
+ userId,
+ companyCode
+ );
res.json({
success: true,
@@ -173,6 +181,7 @@ export class DashboardController {
try {
const { id } = req.params;
const userId = req.user?.userId;
+ const companyCode = req.user?.companyCode;
if (!id) {
res.status(400).json({
@@ -182,7 +191,11 @@ export class DashboardController {
return;
}
- const dashboard = await DashboardService.getDashboardById(id, userId);
+ const dashboard = await DashboardService.getDashboardById(
+ id,
+ userId,
+ companyCode
+ );
if (!dashboard) {
res.status(404).json({
@@ -393,6 +406,8 @@ export class DashboardController {
return;
}
+ const companyCode = req.user?.companyCode;
+
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
@@ -401,7 +416,11 @@ export class DashboardController {
createdBy: userId, // 본인이 만든 대시보드만
};
- const result = await DashboardService.getDashboards(query, userId);
+ const result = await DashboardService.getDashboards(
+ query,
+ userId,
+ companyCode
+ );
res.json({
success: true,
@@ -422,7 +441,7 @@ export class DashboardController {
}
/**
- * 쿼리 실행
+ * 쿼리 실행 (SELECT만)
* POST /api/dashboards/execute-query
*/
async executeQuery(req: AuthenticatedRequest, res: Response): Promise {
@@ -487,6 +506,79 @@ export class DashboardController {
}
}
+ /**
+ * DML 쿼리 실행 (INSERT, UPDATE, DELETE)
+ * POST /api/dashboards/execute-dml
+ */
+ async executeDML(req: AuthenticatedRequest, res: Response): Promise {
+ try {
+ const { query } = req.body;
+
+ // 유효성 검증
+ if (!query || typeof query !== "string" || query.trim().length === 0) {
+ res.status(400).json({
+ success: false,
+ message: "쿼리가 필요합니다.",
+ });
+ return;
+ }
+
+ // SQL 인젝션 방지를 위한 기본적인 검증
+ const trimmedQuery = query.trim().toLowerCase();
+ const allowedCommands = ["insert", "update", "delete"];
+ const isAllowed = allowedCommands.some((cmd) =>
+ trimmedQuery.startsWith(cmd)
+ );
+
+ if (!isAllowed) {
+ res.status(400).json({
+ success: false,
+ message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.",
+ });
+ return;
+ }
+
+ // 위험한 명령어 차단
+ const dangerousPatterns = [
+ /drop\s+table/i,
+ /drop\s+database/i,
+ /truncate/i,
+ /alter\s+table/i,
+ /create\s+table/i,
+ ];
+
+ if (dangerousPatterns.some((pattern) => pattern.test(query))) {
+ res.status(403).json({
+ success: false,
+ message: "허용되지 않는 쿼리입니다.",
+ });
+ return;
+ }
+
+ // 쿼리 실행
+ const result = await PostgreSQLService.query(query.trim());
+
+ res.status(200).json({
+ success: true,
+ data: {
+ rowCount: result.rowCount || 0,
+ command: result.command,
+ },
+ message: "쿼리가 성공적으로 실행되었습니다.",
+ });
+ } catch (error) {
+ console.error("DML execution error:", error);
+ res.status(500).json({
+ success: false,
+ message: "쿼리 실행 중 오류가 발생했습니다.",
+ error:
+ process.env.NODE_ENV === "development"
+ ? (error as Error).message
+ : "쿼리 실행 오류",
+ });
+ }
+ }
+
/**
* 외부 API 프록시 (CORS 우회용)
* POST /api/dashboards/fetch-external-api
diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts
index bc3e6f52..a5b2f225 100644
--- a/backend-node/src/controllers/dynamicFormController.ts
+++ b/backend-node/src/controllers/dynamicFormController.ts
@@ -36,10 +36,18 @@ export const saveFormData = async (
formDataWithMeta.company_code = companyCode;
}
+ // 클라이언트 IP 주소 추출
+ const ipAddress =
+ req.ip ||
+ (req.headers["x-forwarded-for"] as string) ||
+ req.socket.remoteAddress ||
+ "unknown";
+
const result = await dynamicFormService.saveFormData(
screenId,
tableName,
- formDataWithMeta
+ formDataWithMeta,
+ ipAddress
);
res.json({
diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts
index e555e6f7..f596af97 100644
--- a/backend-node/src/controllers/flowController.ts
+++ b/backend-node/src/controllers/flowController.ts
@@ -31,30 +31,41 @@ 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";
- if (!name || !tableName) {
+ console.log("🔍 createFlowDefinition called with:", {
+ name,
+ description,
+ tableName,
+ dbSourceType,
+ dbConnectionId,
+ });
+
+ if (!name) {
res.status(400).json({
success: false,
- message: "Name and tableName are required",
+ message: "Name is required",
});
return;
}
- // 테이블 존재 확인
- const tableExists =
- await this.flowDefinitionService.checkTableExists(tableName);
- if (!tableExists) {
- res.status(400).json({
- success: false,
- message: `Table '${tableName}' does not exist`,
- });
- return;
+ // 테이블 이름이 제공된 경우에만 존재 확인
+ if (tableName) {
+ const tableExists =
+ await this.flowDefinitionService.checkTableExists(tableName);
+ if (!tableExists) {
+ res.status(400).json({
+ success: false,
+ message: `Table '${tableName}' does not exist`,
+ });
+ return;
+ }
}
const flowDef = await this.flowDefinitionService.create(
- { name, description, tableName },
+ { name, description, tableName, dbSourceType, dbConnectionId },
userId
);
@@ -294,6 +305,13 @@ export class FlowController {
color,
positionX,
positionY,
+ moveType,
+ statusColumn,
+ statusValue,
+ targetTable,
+ fieldMappings,
+ integrationType,
+ integrationConfig,
} = req.body;
const step = await this.flowStepService.update(id, {
@@ -304,6 +322,13 @@ export class FlowController {
color,
positionX,
positionY,
+ moveType,
+ statusColumn,
+ statusValue,
+ targetTable,
+ fieldMappings,
+ integrationType,
+ integrationConfig,
});
if (!step) {
diff --git a/backend-node/src/controllers/mailReceiveBasicController.ts b/backend-node/src/controllers/mailReceiveBasicController.ts
index 7722840d..2de79185 100644
--- a/backend-node/src/controllers/mailReceiveBasicController.ts
+++ b/backend-node/src/controllers/mailReceiveBasicController.ts
@@ -18,11 +18,11 @@ export class MailReceiveBasicController {
*/
async getMailList(req: Request, res: Response) {
try {
- console.log('📬 메일 목록 조회 요청:', {
- params: req.params,
- path: req.path,
- originalUrl: req.originalUrl
- });
+ // console.log('📬 메일 목록 조회 요청:', {
+ // params: req.params,
+ // path: req.path,
+ // originalUrl: req.originalUrl
+ // });
const { accountId } = req.params;
const limit = parseInt(req.query.limit as string) || 50;
@@ -49,11 +49,11 @@ export class MailReceiveBasicController {
*/
async getMailDetail(req: Request, res: Response) {
try {
- console.log('🔍 메일 상세 조회 요청:', {
- params: req.params,
- path: req.path,
- originalUrl: req.originalUrl
- });
+ // console.log('🔍 메일 상세 조회 요청:', {
+ // params: req.params,
+ // path: req.path,
+ // originalUrl: req.originalUrl
+ // });
const { accountId, seqno } = req.params;
const seqnoNumber = parseInt(seqno, 10);
@@ -121,39 +121,39 @@ export class MailReceiveBasicController {
*/
async downloadAttachment(req: Request, res: Response) {
try {
- console.log('📎🎯 컨트롤러 downloadAttachment 진입');
+ // console.log('📎🎯 컨트롤러 downloadAttachment 진입');
const { accountId, seqno, index } = req.params;
- console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
+ // console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
const seqnoNumber = parseInt(seqno, 10);
const indexNumber = parseInt(index, 10);
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
- console.log('❌ 유효하지 않은 파라미터');
+ // console.log('❌ 유효하지 않은 파라미터');
return res.status(400).json({
success: false,
message: '유효하지 않은 파라미터입니다.',
});
}
- console.log('📎 서비스 호출 시작...');
+ // console.log('📎 서비스 호출 시작...');
const result = await this.mailReceiveService.downloadAttachment(
accountId,
seqnoNumber,
indexNumber
);
- console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
+ // console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
if (!result) {
- console.log('❌ 첨부파일을 찾을 수 없음');
+ // console.log('❌ 첨부파일을 찾을 수 없음');
return res.status(404).json({
success: false,
message: '첨부파일을 찾을 수 없습니다.',
});
}
- console.log(`📎 파일 다운로드 시작: ${result.filename}`);
- console.log(`📎 파일 경로: ${result.filePath}`);
+ // console.log(`📎 파일 다운로드 시작: ${result.filename}`);
+ // console.log(`📎 파일 경로: ${result.filePath}`);
// 파일 다운로드
res.download(result.filePath, result.filename, (err) => {
@@ -217,5 +217,35 @@ export class MailReceiveBasicController {
});
}
}
+
+ /**
+ * DELETE /api/mail/receive/:accountId/:seqno
+ * IMAP 서버에서 메일 삭제
+ */
+ async deleteMail(req: Request, res: Response) {
+ try {
+ const { accountId, seqno } = req.params;
+ const seqnoNumber = parseInt(seqno, 10);
+
+ if (isNaN(seqnoNumber)) {
+ return res.status(400).json({
+ success: false,
+ message: '유효하지 않은 메일 번호입니다.',
+ });
+ }
+
+ const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber);
+
+ return res.status(200).json(result);
+ } catch (error: unknown) {
+ console.error('메일 삭제 실패:', error);
+ return res.status(500).json({
+ success: false,
+ message: error instanceof Error ? error.message : '메일 삭제 실패',
+ });
+ }
+ }
}
+
+export const mailReceiveBasicController = new MailReceiveBasicController();
diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts
index de8610b7..4736e98c 100644
--- a/backend-node/src/controllers/mailSendSimpleController.ts
+++ b/backend-node/src/controllers/mailSendSimpleController.ts
@@ -7,14 +7,14 @@ export class MailSendSimpleController {
*/
async sendMail(req: Request, res: Response) {
try {
- console.log('📧 메일 발송 요청 수신:', {
- accountId: req.body.accountId,
- to: req.body.to,
- cc: req.body.cc,
- bcc: req.body.bcc,
- subject: req.body.subject,
- attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
- });
+ // console.log('📧 메일 발송 요청 수신:', {
+ // accountId: req.body.accountId,
+ // to: req.body.to,
+ // cc: req.body.cc,
+ // bcc: req.body.bcc,
+ // subject: req.body.subject,
+ // attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
+ // });
// FormData에서 JSON 문자열 파싱
const accountId = req.body.accountId;
@@ -31,7 +31,7 @@ export class MailSendSimpleController {
// 필수 파라미터 검증
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
- console.log('❌ 필수 파라미터 누락');
+ // console.log('❌ 필수 파라미터 누락');
return res.status(400).json({
success: false,
message: '계정 ID와 수신자 이메일이 필요합니다.',
@@ -63,9 +63,9 @@ export class MailSendSimpleController {
if (req.body.fileNames) {
try {
parsedFileNames = JSON.parse(req.body.fileNames);
- console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
+ // console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
} catch (e) {
- console.warn('파일명 파싱 실패, multer originalname 사용');
+ // console.warn('파일명 파싱 실패, multer originalname 사용');
}
}
@@ -83,10 +83,10 @@ export class MailSendSimpleController {
});
});
- console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
- filename: a.filename,
- path: a.path.split('/').pop()
- })));
+ // console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
+ // filename: a.filename,
+ // path: a.path.split('/').pop()
+ // })));
}
// 메일 발송
@@ -125,6 +125,63 @@ export class MailSendSimpleController {
}
}
+ /**
+ * 대량 메일 발송
+ */
+ async sendBulkMail(req: Request, res: Response) {
+ try {
+ const { accountId, templateId, customHtml, subject, recipients } = req.body;
+
+ // 필수 파라미터 검증
+ if (!accountId || !subject || !recipients || !Array.isArray(recipients)) {
+ return res.status(400).json({
+ success: false,
+ message: '필수 파라미터가 누락되었습니다.',
+ });
+ }
+
+ // 템플릿 또는 직접 작성 중 하나는 있어야 함
+ if (!templateId && !customHtml) {
+ return res.status(400).json({
+ success: false,
+ message: '템플릿 또는 메일 내용 중 하나는 필수입니다.',
+ });
+ }
+
+ if (recipients.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '수신자가 없습니다.',
+ });
+ }
+
+ // console.log(`📧 대량 발송 요청: ${recipients.length}명`);
+
+ // 대량 발송 실행
+ const result = await mailSendSimpleService.sendBulkMail({
+ accountId,
+ templateId, // 선택
+ customHtml, // 선택
+ subject,
+ recipients,
+ });
+
+ return res.json({
+ success: true,
+ data: result,
+ message: `${result.success}/${result.total} 건 발송 완료`,
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('❌ 대량 발송 오류:', err);
+ return res.status(500).json({
+ success: false,
+ message: '대량 발송 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
/**
* SMTP 연결 테스트
*/
diff --git a/backend-node/src/controllers/mailSentHistoryController.ts b/backend-node/src/controllers/mailSentHistoryController.ts
index 129d72a7..5451862f 100644
--- a/backend-node/src/controllers/mailSentHistoryController.ts
+++ b/backend-node/src/controllers/mailSentHistoryController.ts
@@ -11,12 +11,14 @@ export class MailSentHistoryController {
page: req.query.page ? parseInt(req.query.page as string) : undefined,
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
searchTerm: req.query.searchTerm as string | undefined,
- status: req.query.status as 'success' | 'failed' | 'all' | undefined,
+ status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined,
accountId: req.query.accountId as string | undefined,
startDate: req.query.startDate as string | undefined,
endDate: req.query.endDate as string | undefined,
- sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined,
+ sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | undefined,
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
+ includeDeleted: req.query.includeDeleted === 'true',
+ onlyDeleted: req.query.onlyDeleted === 'true',
};
const result = await mailSentHistoryService.getSentMailList(query);
@@ -112,6 +114,144 @@ export class MailSentHistoryController {
}
}
+ /**
+ * 임시 저장 (Draft)
+ */
+ async saveDraft(req: Request, res: Response) {
+ try {
+ const draft = await mailSentHistoryService.saveDraft(req.body);
+
+ return res.json({
+ success: true,
+ data: draft,
+ message: '임시 저장되었습니다.',
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('임시 저장 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '임시 저장 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
+ /**
+ * 임시 저장 업데이트
+ */
+ async updateDraft(req: Request, res: Response) {
+ try {
+ const { id } = req.params;
+
+ if (!id) {
+ return res.status(400).json({
+ success: false,
+ message: '임시 저장 ID가 필요합니다.',
+ });
+ }
+
+ const updated = await mailSentHistoryService.updateDraft(id, req.body);
+
+ if (!updated) {
+ return res.status(404).json({
+ success: false,
+ message: '임시 저장을 찾을 수 없습니다.',
+ });
+ }
+
+ return res.json({
+ success: true,
+ data: updated,
+ message: '임시 저장이 업데이트되었습니다.',
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('임시 저장 업데이트 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '임시 저장 업데이트 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
+ /**
+ * 메일 복구
+ */
+ async restoreMail(req: Request, res: Response) {
+ try {
+ const { id } = req.params;
+
+ if (!id) {
+ return res.status(400).json({
+ success: false,
+ message: '메일 ID가 필요합니다.',
+ });
+ }
+
+ const success = await mailSentHistoryService.restoreMail(id);
+
+ if (!success) {
+ return res.status(404).json({
+ success: false,
+ message: '복구할 메일을 찾을 수 없습니다.',
+ });
+ }
+
+ return res.json({
+ success: true,
+ message: '메일이 복구되었습니다.',
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('메일 복구 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '메일 복구 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
+ /**
+ * 메일 영구 삭제
+ */
+ async permanentlyDelete(req: Request, res: Response) {
+ try {
+ const { id } = req.params;
+
+ if (!id) {
+ return res.status(400).json({
+ success: false,
+ message: '메일 ID가 필요합니다.',
+ });
+ }
+
+ const success = await mailSentHistoryService.permanentlyDeleteMail(id);
+
+ if (!success) {
+ return res.status(404).json({
+ success: false,
+ message: '삭제할 메일을 찾을 수 없습니다.',
+ });
+ }
+
+ return res.json({
+ success: true,
+ message: '메일이 영구 삭제되었습니다.',
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('메일 영구 삭제 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '메일 영구 삭제 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
/**
* 통계 조회
*/
@@ -134,6 +274,117 @@ export class MailSentHistoryController {
});
}
}
+
+ /**
+ * 일괄 삭제
+ */
+ async bulkDelete(req: Request, res: Response) {
+ try {
+ const { ids } = req.body;
+
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '삭제할 메일 ID 목록이 필요합니다.',
+ });
+ }
+
+ const results = await Promise.allSettled(
+ ids.map((id: string) => mailSentHistoryService.deleteSentMail(id))
+ );
+
+ const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
+ const failCount = results.length - successCount;
+
+ return res.json({
+ success: true,
+ message: `${successCount}개 메일 삭제 완료 (실패: ${failCount}개)`,
+ data: { successCount, failCount },
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('일괄 삭제 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '일괄 삭제 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
+ /**
+ * 일괄 영구 삭제
+ */
+ async bulkPermanentDelete(req: Request, res: Response) {
+ try {
+ const { ids } = req.body;
+
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '영구 삭제할 메일 ID 목록이 필요합니다.',
+ });
+ }
+
+ const results = await Promise.allSettled(
+ ids.map((id: string) => mailSentHistoryService.permanentlyDeleteMail(id))
+ );
+
+ const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
+ const failCount = results.length - successCount;
+
+ return res.json({
+ success: true,
+ message: `${successCount}개 메일 영구 삭제 완료 (실패: ${failCount}개)`,
+ data: { successCount, failCount },
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('일괄 영구 삭제 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '일괄 영구 삭제 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
+
+ /**
+ * 일괄 복구
+ */
+ async bulkRestore(req: Request, res: Response) {
+ try {
+ const { ids } = req.body;
+
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: '복구할 메일 ID 목록이 필요합니다.',
+ });
+ }
+
+ const results = await Promise.allSettled(
+ ids.map((id: string) => mailSentHistoryService.restoreMail(id))
+ );
+
+ const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
+ const failCount = results.length - successCount;
+
+ return res.json({
+ success: true,
+ message: `${successCount}개 메일 복구 완료 (실패: ${failCount}개)`,
+ data: { successCount, failCount },
+ });
+ } catch (error: unknown) {
+ const err = error as Error;
+ console.error('일괄 복구 실패:', err);
+ return res.status(500).json({
+ success: false,
+ message: '일괄 복구 중 오류가 발생했습니다.',
+ error: err.message,
+ });
+ }
+ }
}
export const mailSentHistoryController = new MailSentHistoryController();
diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts
index b84dc218..d7cf570e 100644
--- a/backend-node/src/controllers/openApiProxyController.ts
+++ b/backend-node/src/controllers/openApiProxyController.ts
@@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe
clouds = 30;
}
+ // 격자좌표 → 위도경도 변환
+ const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny);
+
return {
city: gridCoord.name,
country: 'KR',
+ lat,
+ lng,
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.round(humidity),
@@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe
return grids[city] || null;
}
+/**
+ * 격자좌표(nx, ny)를 위도경도로 변환
+ * 기상청 격자 → 위경도 변환 공식 사용
+ */
+function gridToLatLng(nx: number, ny: number): { lat: number; lng: number } {
+ const RE = 6371.00877; // 지구 반경(km)
+ const GRID = 5.0; // 격자 간격(km)
+ const SLAT1 = 30.0; // 표준위도1(degree)
+ const SLAT2 = 60.0; // 표준위도2(degree)
+ const OLON = 126.0; // 기준점 경도(degree)
+ const OLAT = 38.0; // 기준점 위도(degree)
+ const XO = 43; // 기준점 X좌표
+ const YO = 136; // 기준점 Y좌표
+
+ const DEGRAD = Math.PI / 180.0;
+ const re = RE / GRID;
+ const slat1 = SLAT1 * DEGRAD;
+ const slat2 = SLAT2 * DEGRAD;
+ const olon = OLON * DEGRAD;
+ const olat = OLAT * DEGRAD;
+
+ const sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
+ const sn_log = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
+ const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
+ const sf_pow = Math.pow(sf, sn_log);
+ const sf_result = (Math.cos(slat1) * sf_pow) / sn_log;
+ const ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
+ const ro_pow = Math.pow(ro, sn_log);
+ const ro_result = (re * sf_result) / ro_pow;
+
+ const xn = nx - XO;
+ const yn = ro_result - (ny - YO);
+ const ra = Math.sqrt(xn * xn + yn * yn);
+ let alat: number;
+
+ if (sn_log > 0) {
+ alat = 2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) - Math.PI * 0.5;
+ } else {
+ alat = -2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) + Math.PI * 0.5;
+ }
+
+ let theta: number;
+ if (Math.abs(xn) <= 0.0) {
+ theta = 0.0;
+ } else {
+ if (Math.abs(yn) <= 0.0) {
+ theta = 0.0;
+ } else {
+ theta = Math.atan2(xn, yn);
+ }
+ }
+ const alon = theta / sn_log + olon;
+
+ return {
+ lat: parseFloat((alat / DEGRAD).toFixed(6)),
+ lng: parseFloat((alon / DEGRAD).toFixed(6)),
+ };
+}
+
/**
* 공공데이터포털 초단기실황 응답 파싱
* @param apiResponse - 공공데이터포털 API 응답 데이터
@@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string;
weatherDescription = '추움';
}
+ // 격자좌표 → 위도경도 변환
+ const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny);
+
return {
city: gridInfo.name,
+ lat,
+ lng,
temperature: Math.round(temperature * 10) / 10,
humidity: Math.round(humidity),
windSpeed: Math.round(windSpeed * 10) / 10,
diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index aac86625..d7b2bd74 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -1048,3 +1048,268 @@ export async function updateColumnWebType(
res.status(500).json(response);
}
}
+
+// ========================================
+// 🎯 테이블 로그 시스템 API
+// ========================================
+
+/**
+ * 로그 테이블 생성
+ */
+export async function createLogTable(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName } = req.params;
+ const { pkColumn } = req.body;
+ const userId = req.user?.userId;
+
+ logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
+
+ if (!tableName) {
+ const response: ApiResponse = {
+ success: false,
+ message: "테이블명이 필요합니다.",
+ error: {
+ code: "MISSING_TABLE_NAME",
+ details: "테이블명 파라미터가 누락되었습니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
+ const response: ApiResponse = {
+ success: false,
+ message: "PK 컬럼 정보가 필요합니다.",
+ error: {
+ code: "MISSING_PK_COLUMN",
+ details: "PK 컬럼명과 데이터 타입이 필요합니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ const tableManagementService = new TableManagementService();
+ await tableManagementService.createLogTable(tableName, pkColumn, userId);
+
+ logger.info(`로그 테이블 생성 완료: ${tableName}_log`);
+
+ const response: ApiResponse = {
+ success: true,
+ message: "로그 테이블이 성공적으로 생성되었습니다.",
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error("로그 테이블 생성 중 오류 발생:", error);
+
+ const response: ApiResponse = {
+ success: false,
+ message: "로그 테이블 생성 중 오류가 발생했습니다.",
+ error: {
+ code: "LOG_TABLE_CREATE_ERROR",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ };
+
+ res.status(500).json(response);
+ }
+}
+
+/**
+ * 로그 설정 조회
+ */
+export async function getLogConfig(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName } = req.params;
+
+ logger.info(`=== 로그 설정 조회: ${tableName} ===`);
+
+ if (!tableName) {
+ const response: ApiResponse = {
+ success: false,
+ message: "테이블명이 필요합니다.",
+ error: {
+ code: "MISSING_TABLE_NAME",
+ details: "테이블명 파라미터가 누락되었습니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ const tableManagementService = new TableManagementService();
+ const logConfig = await tableManagementService.getLogConfig(tableName);
+
+ const response: ApiResponse = {
+ success: true,
+ message: "로그 설정을 조회했습니다.",
+ data: logConfig,
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error("로그 설정 조회 중 오류 발생:", error);
+
+ const response: ApiResponse = {
+ success: false,
+ message: "로그 설정 조회 중 오류가 발생했습니다.",
+ error: {
+ code: "LOG_CONFIG_ERROR",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ };
+
+ res.status(500).json(response);
+ }
+}
+
+/**
+ * 로그 데이터 조회
+ */
+export async function getLogData(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName } = req.params;
+ const {
+ page = 1,
+ size = 20,
+ operationType,
+ startDate,
+ endDate,
+ changedBy,
+ originalId,
+ } = req.query;
+
+ logger.info(`=== 로그 데이터 조회: ${tableName} ===`);
+
+ if (!tableName) {
+ const response: ApiResponse = {
+ success: false,
+ message: "테이블명이 필요합니다.",
+ error: {
+ code: "MISSING_TABLE_NAME",
+ details: "테이블명 파라미터가 누락되었습니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ const tableManagementService = new TableManagementService();
+ const result = await tableManagementService.getLogData(tableName, {
+ page: parseInt(page as string),
+ size: parseInt(size as string),
+ operationType: operationType as string,
+ startDate: startDate as string,
+ endDate: endDate as string,
+ changedBy: changedBy as string,
+ originalId: originalId as string,
+ });
+
+ logger.info(
+ `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`
+ );
+
+ const response: ApiResponse = {
+ success: true,
+ message: "로그 데이터를 조회했습니다.",
+ data: result,
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error("로그 데이터 조회 중 오류 발생:", error);
+
+ const response: ApiResponse = {
+ success: false,
+ message: "로그 데이터 조회 중 오류가 발생했습니다.",
+ error: {
+ code: "LOG_DATA_ERROR",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ };
+
+ res.status(500).json(response);
+ }
+}
+
+/**
+ * 로그 테이블 활성화/비활성화
+ */
+export async function toggleLogTable(
+ req: AuthenticatedRequest,
+ res: Response
+): Promise {
+ try {
+ const { tableName } = req.params;
+ const { isActive } = req.body;
+
+ logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
+
+ if (!tableName) {
+ const response: ApiResponse = {
+ success: false,
+ message: "테이블명이 필요합니다.",
+ error: {
+ code: "MISSING_TABLE_NAME",
+ details: "테이블명 파라미터가 누락되었습니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ if (isActive === undefined || isActive === null) {
+ const response: ApiResponse = {
+ success: false,
+ message: "isActive 값이 필요합니다.",
+ error: {
+ code: "MISSING_IS_ACTIVE",
+ details: "isActive 파라미터가 누락되었습니다.",
+ },
+ };
+ res.status(400).json(response);
+ return;
+ }
+
+ const tableManagementService = new TableManagementService();
+ await tableManagementService.toggleLogTable(
+ tableName,
+ isActive === "Y" || isActive === true
+ );
+
+ logger.info(
+ `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
+ );
+
+ const response: ApiResponse = {
+ success: true,
+ message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
+ };
+
+ res.status(200).json(response);
+ } catch (error) {
+ logger.error("로그 테이블 토글 중 오류 발생:", error);
+
+ const response: ApiResponse = {
+ success: false,
+ message: "로그 테이블 토글 중 오류가 발생했습니다.",
+ error: {
+ code: "LOG_TOGGLE_ERROR",
+ details: error instanceof Error ? error.message : "Unknown error",
+ },
+ };
+
+ res.status(500).json(response);
+ }
+}
diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts
index 87db696b..2356d05d 100644
--- a/backend-node/src/routes/dashboardRoutes.ts
+++ b/backend-node/src/routes/dashboardRoutes.ts
@@ -24,12 +24,18 @@ router.get(
dashboardController.getDashboard.bind(dashboardController)
);
-// 쿼리 실행 (인증 불필요 - 개발용)
+// 쿼리 실행 (SELECT만, 인증 불필요 - 개발용)
router.post(
"/execute-query",
dashboardController.executeQuery.bind(dashboardController)
);
+// DML 쿼리 실행 (INSERT/UPDATE/DELETE, 인증 불필요 - 개발용)
+router.post(
+ "/execute-dml",
+ dashboardController.executeDML.bind(dashboardController)
+);
+
// 외부 API 프록시 (CORS 우회)
router.post(
"/fetch-external-api",
diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts
new file mode 100644
index 00000000..0e2de684
--- /dev/null
+++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts
@@ -0,0 +1,252 @@
+import { Router, Request, Response } from "express";
+import {
+ authenticateToken,
+ AuthenticatedRequest,
+} from "../middleware/authMiddleware";
+import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
+import {
+ ExternalRestApiConnection,
+ ExternalRestApiConnectionFilter,
+ RestApiTestRequest,
+} from "../types/externalRestApiTypes";
+import logger from "../utils/logger";
+
+const router = Router();
+
+/**
+ * GET /api/external-rest-api-connections
+ * REST API 연결 목록 조회
+ */
+router.get(
+ "/",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const filter: ExternalRestApiConnectionFilter = {
+ search: req.query.search as string,
+ auth_type: req.query.auth_type as string,
+ is_active: req.query.is_active as string,
+ company_code: req.query.company_code as string,
+ };
+
+ const result =
+ await ExternalRestApiConnectionService.getConnections(filter);
+
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 목록 조회 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * GET /api/external-rest-api-connections/:id
+ * REST API 연결 상세 조회
+ */
+router.get(
+ "/:id",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const id = parseInt(req.params.id);
+
+ if (isNaN(id)) {
+ return res.status(400).json({
+ success: false,
+ message: "유효하지 않은 ID입니다.",
+ });
+ }
+
+ const result =
+ await ExternalRestApiConnectionService.getConnectionById(id);
+
+ return res.status(result.success ? 200 : 404).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 상세 조회 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * POST /api/external-rest-api-connections
+ * REST API 연결 생성
+ */
+router.post(
+ "/",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const data: ExternalRestApiConnection = {
+ ...req.body,
+ created_by: req.user?.userId || "system",
+ };
+
+ const result =
+ await ExternalRestApiConnectionService.createConnection(data);
+
+ return res.status(result.success ? 201 : 400).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 생성 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * PUT /api/external-rest-api-connections/:id
+ * REST API 연결 수정
+ */
+router.put(
+ "/:id",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const id = parseInt(req.params.id);
+
+ if (isNaN(id)) {
+ return res.status(400).json({
+ success: false,
+ message: "유효하지 않은 ID입니다.",
+ });
+ }
+
+ const data: Partial = {
+ ...req.body,
+ updated_by: req.user?.userId || "system",
+ };
+
+ const result = await ExternalRestApiConnectionService.updateConnection(
+ id,
+ data
+ );
+
+ return res.status(result.success ? 200 : 400).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 수정 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * DELETE /api/external-rest-api-connections/:id
+ * REST API 연결 삭제
+ */
+router.delete(
+ "/:id",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const id = parseInt(req.params.id);
+
+ if (isNaN(id)) {
+ return res.status(400).json({
+ success: false,
+ message: "유효하지 않은 ID입니다.",
+ });
+ }
+
+ const result =
+ await ExternalRestApiConnectionService.deleteConnection(id);
+
+ return res.status(result.success ? 200 : 404).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 삭제 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * POST /api/external-rest-api-connections/test
+ * REST API 연결 테스트 (테스트 데이터 기반)
+ */
+router.post(
+ "/test",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const testRequest: RestApiTestRequest = req.body;
+
+ if (!testRequest.base_url) {
+ return res.status(400).json({
+ success: false,
+ message: "기본 URL은 필수입니다.",
+ });
+ }
+
+ const result =
+ await ExternalRestApiConnectionService.testConnection(testRequest);
+
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 테스트 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+/**
+ * POST /api/external-rest-api-connections/:id/test
+ * REST API 연결 테스트 (ID 기반)
+ */
+router.post(
+ "/:id/test",
+ authenticateToken,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const id = parseInt(req.params.id);
+
+ if (isNaN(id)) {
+ return res.status(400).json({
+ success: false,
+ message: "유효하지 않은 ID입니다.",
+ });
+ }
+
+ const endpoint = req.body.endpoint as string | undefined;
+
+ const result = await ExternalRestApiConnectionService.testConnectionById(
+ id,
+ endpoint
+ );
+
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error("REST API 연결 테스트 (ID) 오류:", error);
+ return res.status(500).json({
+ success: false,
+ message: "서버 내부 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ }
+);
+
+export default router;
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/routes/mailReceiveBasicRoutes.ts b/backend-node/src/routes/mailReceiveBasicRoutes.ts
index d40c4629..60676ef6 100644
--- a/backend-node/src/routes/mailReceiveBasicRoutes.ts
+++ b/backend-node/src/routes/mailReceiveBasicRoutes.ts
@@ -27,6 +27,9 @@ router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
// 메일 읽음 표시 - 구체적인 경로
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
+// 메일 삭제 - 구체적인 경로
+router.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res));
+
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
diff --git a/backend-node/src/routes/mailSendSimpleRoutes.ts b/backend-node/src/routes/mailSendSimpleRoutes.ts
index f354957c..12c1ccff 100644
--- a/backend-node/src/routes/mailSendSimpleRoutes.ts
+++ b/backend-node/src/routes/mailSendSimpleRoutes.ts
@@ -15,6 +15,9 @@ router.post(
(req, res) => mailSendSimpleController.sendMail(req, res)
);
+// POST /api/mail/send/bulk - 대량 메일 발송
+router.post('/bulk', (req, res) => mailSendSimpleController.sendBulkMail(req, res));
+
// POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
diff --git a/backend-node/src/routes/mailSentHistoryRoutes.ts b/backend-node/src/routes/mailSentHistoryRoutes.ts
index 2f4c6f98..5863eed9 100644
--- a/backend-node/src/routes/mailSentHistoryRoutes.ts
+++ b/backend-node/src/routes/mailSentHistoryRoutes.ts
@@ -7,16 +7,37 @@ const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
+// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의)
+router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
+
// GET /api/mail/sent - 발송 이력 목록 조회
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
-// GET /api/mail/sent/statistics - 통계 조회
-router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
+// POST /api/mail/sent/draft - 임시 저장 (Draft)
+router.post('/draft', (req, res) => mailSentHistoryController.saveDraft(req, res));
+
+// PUT /api/mail/sent/draft/:id - 임시 저장 업데이트
+router.put('/draft/:id', (req, res) => mailSentHistoryController.updateDraft(req, res));
+
+// POST /api/mail/sent/bulk/delete - 일괄 삭제
+router.post('/bulk/delete', (req, res) => mailSentHistoryController.bulkDelete(req, res));
+
+// POST /api/mail/sent/bulk/permanent-delete - 일괄 영구 삭제
+router.post('/bulk/permanent-delete', (req, res) => mailSentHistoryController.bulkPermanentDelete(req, res));
+
+// POST /api/mail/sent/bulk/restore - 일괄 복구
+router.post('/bulk/restore', (req, res) => mailSentHistoryController.bulkRestore(req, res));
+
+// POST /api/mail/sent/:id/restore - 메일 복구
+router.post('/:id/restore', (req, res) => mailSentHistoryController.restoreMail(req, res));
+
+// DELETE /api/mail/sent/:id/permanent - 메일 영구 삭제
+router.delete('/:id/permanent', (req, res) => mailSentHistoryController.permanentlyDelete(req, res));
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
-// DELETE /api/mail/sent/:id - 발송 이력 삭제
+// DELETE /api/mail/sent/:id - 발송 이력 삭제 (Soft Delete)
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
export default router;
diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts
index c0b35b94..5e5ddf38 100644
--- a/backend-node/src/routes/tableManagementRoutes.ts
+++ b/backend-node/src/routes/tableManagementRoutes.ts
@@ -18,6 +18,10 @@ import {
checkTableExists,
getColumnWebTypes,
checkDatabaseConnection,
+ createLogTable,
+ getLogConfig,
+ getLogData,
+ toggleLogTable,
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData);
*/
router.delete("/tables/:tableName/delete", deleteTableData);
+// ========================================
+// 테이블 로그 시스템 API
+// ========================================
+
+/**
+ * 로그 테이블 생성
+ * POST /api/table-management/tables/:tableName/log
+ */
+router.post("/tables/:tableName/log", createLogTable);
+
+/**
+ * 로그 설정 조회
+ * GET /api/table-management/tables/:tableName/log/config
+ */
+router.get("/tables/:tableName/log/config", getLogConfig);
+
+/**
+ * 로그 데이터 조회
+ * GET /api/table-management/tables/:tableName/log
+ */
+router.get("/tables/:tableName/log", getLogData);
+
+/**
+ * 로그 테이블 활성화/비활성화
+ * POST /api/table-management/tables/:tableName/log/toggle
+ */
+router.post("/tables/:tableName/log/toggle", toggleLogTable);
+
export default router;
diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts
index c7650df2..92b5ed39 100644
--- a/backend-node/src/services/DashboardService.ts
+++ b/backend-node/src/services/DashboardService.ts
@@ -18,7 +18,8 @@ export class DashboardService {
*/
static async createDashboard(
data: CreateDashboardRequest,
- userId: string
+ userId: string,
+ companyCode?: string
): Promise {
const dashboardId = uuidv4();
const now = new Date();
@@ -31,8 +32,8 @@ export class DashboardService {
`
INSERT INTO dashboards (
id, title, description, is_public, created_by,
- created_at, updated_at, tags, category, view_count, settings
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ created_at, updated_at, tags, category, view_count, settings, company_code
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`,
[
dashboardId,
@@ -46,6 +47,7 @@ export class DashboardService {
data.category || null,
0,
JSON.stringify(data.settings || {}),
+ companyCode || "DEFAULT",
]
);
@@ -61,9 +63,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
- list_config, yard_config,
+ list_config, yard_config, custom_metric_config,
display_order, created_at, updated_at
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`,
[
elementId,
@@ -82,6 +84,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
+ JSON.stringify(element.customMetricConfig || null),
i,
now,
now,
@@ -143,7 +146,11 @@ export class DashboardService {
/**
* 대시보드 목록 조회
*/
- static async getDashboards(query: DashboardListQuery, userId?: string) {
+ static async getDashboards(
+ query: DashboardListQuery,
+ userId?: string,
+ companyCode?: string
+ ) {
const {
page = 1,
limit = 20,
@@ -161,6 +168,13 @@ export class DashboardService {
let params: any[] = [];
let paramIndex = 1;
+ // 회사 코드 필터링 (최우선)
+ if (companyCode) {
+ whereConditions.push(`d.company_code = $${paramIndex}`);
+ params.push(companyCode);
+ paramIndex++;
+ }
+
// 권한 필터링
if (userId) {
whereConditions.push(
@@ -278,7 +292,8 @@ export class DashboardService {
*/
static async getDashboardById(
dashboardId: string,
- userId?: string
+ userId?: string,
+ companyCode?: string
): Promise {
try {
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
@@ -286,21 +301,43 @@ export class DashboardService {
let dashboardParams: any[];
if (userId) {
- dashboardQuery = `
- SELECT d.*
- FROM dashboards d
- WHERE d.id = $1 AND d.deleted_at IS NULL
- AND (d.created_by = $2 OR d.is_public = true)
- `;
- dashboardParams = [dashboardId, userId];
+ if (companyCode) {
+ dashboardQuery = `
+ SELECT d.*
+ FROM dashboards d
+ WHERE d.id = $1 AND d.deleted_at IS NULL
+ AND d.company_code = $2
+ AND (d.created_by = $3 OR d.is_public = true)
+ `;
+ dashboardParams = [dashboardId, companyCode, userId];
+ } else {
+ dashboardQuery = `
+ SELECT d.*
+ FROM dashboards d
+ WHERE d.id = $1 AND d.deleted_at IS NULL
+ AND (d.created_by = $2 OR d.is_public = true)
+ `;
+ dashboardParams = [dashboardId, userId];
+ }
} else {
- dashboardQuery = `
- SELECT d.*
- FROM dashboards d
- WHERE d.id = $1 AND d.deleted_at IS NULL
- AND d.is_public = true
- `;
- dashboardParams = [dashboardId];
+ if (companyCode) {
+ dashboardQuery = `
+ SELECT d.*
+ FROM dashboards d
+ WHERE d.id = $1 AND d.deleted_at IS NULL
+ AND d.company_code = $2
+ AND d.is_public = true
+ `;
+ dashboardParams = [dashboardId, companyCode];
+ } else {
+ dashboardQuery = `
+ SELECT d.*
+ FROM dashboards d
+ WHERE d.id = $1 AND d.deleted_at IS NULL
+ AND d.is_public = true
+ `;
+ dashboardParams = [dashboardId];
+ }
}
const dashboardResult = await PostgreSQLService.query(
@@ -355,6 +392,11 @@ export class DashboardService {
? JSON.parse(row.yard_config)
: row.yard_config
: undefined,
+ customMetricConfig: row.custom_metric_config
+ ? typeof row.custom_metric_config === "string"
+ ? JSON.parse(row.custom_metric_config)
+ : row.custom_metric_config
+ : undefined,
})
);
@@ -478,9 +520,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
- list_config, yard_config,
+ list_config, yard_config, custom_metric_config,
display_order, created_at, updated_at
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`,
[
elementId,
@@ -499,6 +541,7 @@ export class DashboardService {
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
+ JSON.stringify(element.customMetricConfig || null),
i,
now,
now,
diff --git a/backend-node/src/services/dbQueryBuilder.ts b/backend-node/src/services/dbQueryBuilder.ts
new file mode 100644
index 00000000..e7b8d9bc
--- /dev/null
+++ b/backend-node/src/services/dbQueryBuilder.ts
@@ -0,0 +1,230 @@
+/**
+ * 데이터베이스별 쿼리 빌더
+ * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원
+ */
+
+export type DbType = "postgresql" | "mysql" | "mariadb" | "mssql" | "oracle";
+
+/**
+ * DB별 파라미터 플레이스홀더 생성
+ */
+export function getPlaceholder(dbType: string, index: number): string {
+ const normalizedType = dbType.toLowerCase();
+
+ switch (normalizedType) {
+ case "postgresql":
+ return `$${index}`;
+
+ case "mysql":
+ case "mariadb":
+ return "?";
+
+ case "mssql":
+ return `@p${index}`;
+
+ case "oracle":
+ return `:${index}`;
+
+ default:
+ // 기본값은 PostgreSQL
+ return `$${index}`;
+ }
+}
+
+/**
+ * UPDATE 쿼리 생성
+ */
+export function buildUpdateQuery(
+ dbType: string,
+ tableName: string,
+ updates: { column: string; value: any }[],
+ whereColumn: string = "id"
+): { query: string; values: any[] } {
+ const normalizedType = dbType.toLowerCase();
+ const values: any[] = [];
+
+ // SET 절 생성
+ const setClause = updates
+ .map((update, index) => {
+ values.push(update.value);
+ const placeholder = getPlaceholder(normalizedType, values.length);
+ return `${update.column} = ${placeholder}`;
+ })
+ .join(", ");
+
+ // WHERE 절 생성
+ values.push(undefined); // whereValue는 나중에 설정
+ const wherePlaceholder = getPlaceholder(normalizedType, values.length);
+
+ // updated_at 처리 (DB별 NOW() 함수)
+ let updatedAtExpr = "NOW()";
+ if (normalizedType === "mssql") {
+ updatedAtExpr = "GETDATE()";
+ } else if (normalizedType === "oracle") {
+ updatedAtExpr = "SYSDATE";
+ }
+
+ const query = `
+ UPDATE ${tableName}
+ SET ${setClause}, updated_at = ${updatedAtExpr}
+ WHERE ${whereColumn} = ${wherePlaceholder}
+ `;
+
+ return { query, values };
+}
+
+/**
+ * INSERT 쿼리 생성
+ */
+export function buildInsertQuery(
+ dbType: string,
+ tableName: string,
+ data: Record
+): { query: string; values: any[]; returningClause: string } {
+ const normalizedType = dbType.toLowerCase();
+ const columns = Object.keys(data);
+ const values = Object.values(data);
+
+ // 플레이스홀더 생성
+ const placeholders = columns
+ .map((_, index) => getPlaceholder(normalizedType, index + 1))
+ .join(", ");
+
+ let query = `
+ INSERT INTO ${tableName} (${columns.join(", ")})
+ VALUES (${placeholders})
+ `;
+
+ // RETURNING/OUTPUT 절 추가 (DB별로 다름)
+ let returningClause = "";
+ if (normalizedType === "postgresql") {
+ query += " RETURNING id";
+ returningClause = "RETURNING id";
+ } else if (normalizedType === "mssql") {
+ // MSSQL은 OUTPUT 절을 INSERT와 VALUES 사이에
+ const insertIndex = query.indexOf("VALUES");
+ query =
+ query.substring(0, insertIndex) +
+ "OUTPUT INSERTED.id " +
+ query.substring(insertIndex);
+ returningClause = "OUTPUT INSERTED.id";
+ } else if (normalizedType === "oracle") {
+ query += " RETURNING id INTO :out_id";
+ returningClause = "RETURNING id INTO :out_id";
+ }
+ // MySQL/MariaDB는 RETURNING 없음, LAST_INSERT_ID() 사용
+
+ return { query, values, returningClause };
+}
+
+/**
+ * SELECT 쿼리 생성
+ */
+export function buildSelectQuery(
+ dbType: string,
+ tableName: string,
+ whereColumn: string = "id"
+): { query: string; placeholder: string } {
+ const normalizedType = dbType.toLowerCase();
+ const placeholder = getPlaceholder(normalizedType, 1);
+
+ const query = `SELECT * FROM ${tableName} WHERE ${whereColumn} = ${placeholder}`;
+
+ return { query, placeholder };
+}
+
+/**
+ * LIMIT/OFFSET 쿼리 생성 (페이징)
+ */
+export function buildPaginationClause(
+ dbType: string,
+ limit?: number,
+ offset?: number
+): string {
+ const normalizedType = dbType.toLowerCase();
+
+ if (!limit) {
+ return "";
+ }
+
+ if (
+ normalizedType === "postgresql" ||
+ normalizedType === "mysql" ||
+ normalizedType === "mariadb"
+ ) {
+ // PostgreSQL, MySQL, MariaDB: LIMIT ... OFFSET ...
+ let clause = ` LIMIT ${limit}`;
+ if (offset) {
+ clause += ` OFFSET ${offset}`;
+ }
+ return clause;
+ } else if (normalizedType === "mssql") {
+ // MSSQL: OFFSET ... ROWS FETCH NEXT ... ROWS ONLY
+ if (offset) {
+ return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
+ } else {
+ return ` OFFSET 0 ROWS FETCH NEXT ${limit} ROWS ONLY`;
+ }
+ } else if (normalizedType === "oracle") {
+ // Oracle: ROWNUM 또는 FETCH FIRST (12c+)
+ if (offset) {
+ return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
+ } else {
+ return ` FETCH FIRST ${limit} ROWS ONLY`;
+ }
+ }
+
+ return "";
+}
+
+/**
+ * 트랜잭션 시작
+ */
+export function getBeginTransactionQuery(dbType: string): string {
+ const normalizedType = dbType.toLowerCase();
+
+ if (normalizedType === "mssql") {
+ return "BEGIN TRANSACTION";
+ }
+
+ return "BEGIN";
+}
+
+/**
+ * 트랜잭션 커밋
+ */
+export function getCommitQuery(dbType: string): string {
+ return "COMMIT";
+}
+
+/**
+ * 트랜잭션 롤백
+ */
+export function getRollbackQuery(dbType: string): string {
+ return "ROLLBACK";
+}
+
+/**
+ * DB 연결 테스트 쿼리
+ */
+export function getConnectionTestQuery(dbType: string): string {
+ const normalizedType = dbType.toLowerCase();
+
+ switch (normalizedType) {
+ case "postgresql":
+ return "SELECT 1";
+
+ case "mysql":
+ case "mariadb":
+ return "SELECT 1";
+
+ case "mssql":
+ return "SELECT 1";
+
+ case "oracle":
+ return "SELECT 1 FROM DUAL";
+
+ default:
+ return "SELECT 1";
+ }
+}
diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts
index 331f980e..999ea6d2 100644
--- a/backend-node/src/services/dynamicFormService.ts
+++ b/backend-node/src/services/dynamicFormService.ts
@@ -1,4 +1,4 @@
-import { query, queryOne } from "../database/db";
+import { query, queryOne, transaction } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
@@ -203,7 +203,8 @@ export class DynamicFormService {
async saveFormData(
screenId: number,
tableName: string,
- data: Record
+ data: Record,
+ ipAddress?: string
): Promise {
try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
@@ -432,7 +433,19 @@ export class DynamicFormService {
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
console.log("📊 SQL 파라미터:", values);
- const result = await query(upsertQuery, values);
+ // 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서)
+ const userId = data.updated_by || data.created_by || "system";
+ const clientIp = ipAddress || "unknown";
+
+ const result = await transaction(async (client) => {
+ // 세션 변수 설정
+ await client.query(`SET LOCAL app.user_id = '${userId}'`);
+ await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
+
+ // UPSERT 실행
+ const res = await client.query(upsertQuery, values);
+ return res.rows;
+ });
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
diff --git a/backend-node/src/services/externalDbHelper.ts b/backend-node/src/services/externalDbHelper.ts
new file mode 100644
index 00000000..9a8b4f7d
--- /dev/null
+++ b/backend-node/src/services/externalDbHelper.ts
@@ -0,0 +1,467 @@
+/**
+ * 외부 DB 연결 헬퍼
+ * 플로우 데이터 이동 시 외부 DB 연결 관리
+ * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원
+ */
+
+import { Pool as PgPool } from "pg";
+import * as mysql from "mysql2/promise";
+import db from "../database/db";
+import { PasswordEncryption } from "../utils/passwordEncryption";
+import {
+ getConnectionTestQuery,
+ getPlaceholder,
+ getBeginTransactionQuery,
+ getCommitQuery,
+ getRollbackQuery,
+} from "./dbQueryBuilder";
+
+interface ExternalDbConnection {
+ id: number;
+ connectionName: string;
+ dbType: string;
+ host: string;
+ port: number;
+ database: string;
+ username: string;
+ password: string;
+ isActive: boolean;
+}
+
+// 외부 DB 연결 풀 캐시 (타입별로 다른 풀 객체)
+const connectionPools = new Map();
+
+/**
+ * 외부 DB 연결 정보 조회
+ */
+async function getExternalConnection(
+ connectionId: number
+): Promise {
+ const query = `SELECT * FROM external_db_connections WHERE id = $1 AND is_active = 'Y'`;
+
+ const result = await db.query(query, [connectionId]);
+
+ if (result.length === 0) {
+ return null;
+ }
+
+ const row = result[0];
+
+ // 비밀번호 복호화 (암호화된 비밀번호는 password 컬럼에 저장됨)
+ let decryptedPassword = "";
+ try {
+ decryptedPassword = PasswordEncryption.decrypt(row.password);
+ } catch (error) {
+ console.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error);
+ // 복호화 실패 시 원본 비밀번호 사용 (fallback)
+ decryptedPassword = row.password;
+ }
+
+ return {
+ id: row.id,
+ connectionName: row.connection_name,
+ dbType: row.db_type,
+ host: row.host,
+ port: row.port,
+ database: row.database_name,
+ username: row.username,
+ password: decryptedPassword,
+ isActive: row.is_active,
+ };
+}
+
+/**
+ * 외부 DB 연결 풀 생성 또는 재사용
+ */
+export async function getExternalPool(connectionId: number): Promise {
+ // 캐시된 연결 풀 확인
+ if (connectionPools.has(connectionId)) {
+ const poolInfo = connectionPools.get(connectionId)!;
+ const connection = await getExternalConnection(connectionId);
+
+ // 연결이 유효한지 확인
+ try {
+ const testQuery = getConnectionTestQuery(connection!.dbType);
+ await executePoolQuery(poolInfo.pool, connection!.dbType, testQuery, []);
+ return poolInfo;
+ } catch (error) {
+ console.warn(
+ `캐시된 외부 DB 연결 풀 무효화 (ID: ${connectionId}), 재생성합니다.`
+ );
+ connectionPools.delete(connectionId);
+ await closePool(poolInfo.pool, connection!.dbType);
+ }
+ }
+
+ // 새로운 연결 풀 생성
+ const connection = await getExternalConnection(connectionId);
+
+ if (!connection) {
+ throw new Error(
+ `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`
+ );
+ }
+
+ const dbType = connection.dbType.toLowerCase();
+ let pool: any;
+
+ try {
+ switch (dbType) {
+ case "postgresql":
+ pool = await createPostgreSQLPool(connection);
+ break;
+
+ case "mysql":
+ case "mariadb":
+ pool = await createMySQLPool(connection);
+ break;
+
+ case "mssql":
+ pool = await createMSSQLPool(connection);
+ break;
+
+ case "oracle":
+ pool = await createOraclePool(connection);
+ break;
+
+ default:
+ throw new Error(`지원하지 않는 DB 타입입니다: ${connection.dbType}`);
+ }
+
+ // 연결 테스트
+ const testQuery = getConnectionTestQuery(dbType);
+ await executePoolQuery(pool, dbType, testQuery, []);
+
+ console.log(
+ `✅ 외부 DB 연결 풀 생성 성공 (ID: ${connectionId}, ${connection.connectionName}, ${connection.dbType})`
+ );
+
+ // 캐시에 저장 (dbType 정보 포함)
+ const poolInfo = { pool, dbType };
+ connectionPools.set(connectionId, poolInfo);
+
+ return poolInfo;
+ } catch (error) {
+ if (pool) {
+ await closePool(pool, dbType);
+ }
+ throw new Error(
+ `외부 DB 연결 실패 (${connection.connectionName}, ${connection.dbType}): ${error}`
+ );
+ }
+}
+
+/**
+ * PostgreSQL 연결 풀 생성
+ */
+async function createPostgreSQLPool(
+ connection: ExternalDbConnection
+): Promise {
+ return new PgPool({
+ host: connection.host,
+ port: connection.port,
+ database: connection.database,
+ user: connection.username,
+ password: connection.password,
+ max: 5,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 5000,
+ });
+}
+
+/**
+ * MySQL/MariaDB 연결 풀 생성
+ */
+async function createMySQLPool(
+ connection: ExternalDbConnection
+): Promise {
+ return mysql.createPool({
+ host: connection.host,
+ port: connection.port,
+ database: connection.database,
+ user: connection.username,
+ password: connection.password,
+ connectionLimit: 5,
+ waitForConnections: true,
+ queueLimit: 0,
+ });
+}
+
+/**
+ * MSSQL 연결 풀 생성
+ */
+async function createMSSQLPool(connection: ExternalDbConnection): Promise {
+ // mssql 패키지를 동적으로 import (설치되어 있는 경우만)
+ try {
+ const sql = require("mssql");
+ const config = {
+ user: connection.username,
+ password: connection.password,
+ server: connection.host,
+ port: connection.port,
+ database: connection.database,
+ options: {
+ encrypt: true,
+ trustServerCertificate: true,
+ enableArithAbort: true,
+ },
+ pool: {
+ max: 5,
+ min: 0,
+ idleTimeoutMillis: 30000,
+ },
+ };
+
+ const pool = await sql.connect(config);
+ return pool;
+ } catch (error) {
+ throw new Error(
+ `MSSQL 연결 실패: mssql 패키지가 설치되어 있는지 확인하세요. (${error})`
+ );
+ }
+}
+
+/**
+ * Oracle 연결 풀 생성
+ */
+async function createOraclePool(
+ connection: ExternalDbConnection
+): Promise {
+ try {
+ // oracledb를 동적으로 import
+ const oracledb = require("oracledb");
+
+ // Oracle 클라이언트 초기화 (최초 1회만)
+ if (!oracledb.oracleClientVersion) {
+ // Instant Client 경로 설정 (환경변수로 지정 가능)
+ const instantClientPath = process.env.ORACLE_INSTANT_CLIENT_PATH;
+ if (instantClientPath) {
+ oracledb.initOracleClient({ libDir: instantClientPath });
+ }
+ }
+
+ // 연결 문자열 생성
+ const connectString = connection.database.includes("/")
+ ? connection.database // 이미 전체 연결 문자열인 경우
+ : `${connection.host}:${connection.port}/${connection.database}`;
+
+ const pool = await oracledb.createPool({
+ user: connection.username,
+ password: connection.password,
+ connectString: connectString,
+ poolMin: 1,
+ poolMax: 5,
+ poolIncrement: 1,
+ poolTimeout: 60, // 60초 후 유휴 연결 해제
+ queueTimeout: 5000, // 연결 대기 타임아웃 5초
+ enableStatistics: true,
+ });
+
+ return pool;
+ } catch (error: any) {
+ throw new Error(
+ `Oracle 연결 실패: ${error.message}. oracledb 패키지와 Oracle Instant Client가 설치되어 있는지 확인하세요.`
+ );
+ }
+}
+
+/**
+ * 풀에서 쿼리 실행 (DB 타입별 처리)
+ */
+async function executePoolQuery(
+ pool: any,
+ dbType: string,
+ query: string,
+ params: any[]
+): Promise {
+ const normalizedType = dbType.toLowerCase();
+
+ switch (normalizedType) {
+ case "postgresql": {
+ const result = await pool.query(query, params);
+ return { rows: result.rows, rowCount: result.rowCount };
+ }
+
+ case "mysql":
+ case "mariadb": {
+ const [rows] = await pool.query(query, params);
+ return {
+ rows: Array.isArray(rows) ? rows : [rows],
+ rowCount: rows.length,
+ };
+ }
+
+ case "mssql": {
+ const request = pool.request();
+ // MSSQL은 명명된 파라미터 사용
+ params.forEach((param, index) => {
+ request.input(`p${index + 1}`, param);
+ });
+ const result = await request.query(query);
+ return { rows: result.recordset, rowCount: result.rowCount };
+ }
+
+ case "oracle": {
+ const oracledb = require("oracledb");
+ const connection = await pool.getConnection();
+ try {
+ // Oracle은 :1, :2 형식의 바인드 변수 사용
+ const result = await connection.execute(query, params, {
+ autoCommit: false, // 트랜잭션 관리를 위해 false
+ outFormat: oracledb.OUT_FORMAT_OBJECT, // 객체 형식으로 반환
+ });
+ return { rows: result.rows || [], rowCount: result.rowCount || 0 };
+ } finally {
+ await connection.close();
+ }
+ }
+
+ default:
+ throw new Error(`지원하지 않는 DB 타입: ${dbType}`);
+ }
+}
+
+/**
+ * 연결 풀 종료 (DB 타입별 처리)
+ */
+async function closePool(pool: any, dbType: string): Promise {
+ const normalizedType = dbType.toLowerCase();
+
+ try {
+ switch (normalizedType) {
+ case "postgresql":
+ case "mysql":
+ case "mariadb":
+ await pool.end();
+ break;
+
+ case "mssql":
+ case "oracle":
+ await pool.close();
+ break;
+ }
+ } catch (error) {
+ console.error(`풀 종료 오류 (${dbType}):`, error);
+ }
+}
+
+/**
+ * 외부 DB 쿼리 실행
+ */
+export async function executeExternalQuery(
+ connectionId: number,
+ query: string,
+ params: any[] = []
+): Promise {
+ const poolInfo = await getExternalPool(connectionId);
+ return await executePoolQuery(poolInfo.pool, poolInfo.dbType, query, params);
+}
+
+/**
+ * 외부 DB 트랜잭션 실행
+ */
+export async function executeExternalTransaction(
+ connectionId: number,
+ callback: (client: any, dbType: string) => Promise
+): Promise {
+ const poolInfo = await getExternalPool(connectionId);
+ const { pool, dbType } = poolInfo;
+ const normalizedType = dbType.toLowerCase();
+
+ let client: any;
+
+ try {
+ switch (normalizedType) {
+ case "postgresql": {
+ client = await pool.connect();
+ await client.query(getBeginTransactionQuery(dbType));
+ const result = await callback(client, dbType);
+ await client.query(getCommitQuery(dbType));
+ return result;
+ }
+
+ case "mysql":
+ case "mariadb": {
+ client = await pool.getConnection();
+ await client.beginTransaction();
+ const result = await callback(client, dbType);
+ await client.commit();
+ return result;
+ }
+
+ case "mssql": {
+ const transaction = new pool.constructor.Transaction(pool);
+ await transaction.begin();
+ client = transaction;
+ const result = await callback(client, dbType);
+ await transaction.commit();
+ return result;
+ }
+
+ case "oracle": {
+ client = await pool.getConnection();
+ // Oracle은 명시적 BEGIN 없이 트랜잭션 시작
+ const result = await callback(client, dbType);
+ // 명시적 커밋
+ await client.commit();
+ return result;
+ }
+
+ default:
+ throw new Error(`지원하지 않는 DB 타입: ${dbType}`);
+ }
+ } catch (error) {
+ console.error(`외부 DB 트랜잭션 오류 (ID: ${connectionId}):`, error);
+
+ // 롤백 시도
+ if (client) {
+ try {
+ switch (normalizedType) {
+ case "postgresql":
+ await client.query(getRollbackQuery(dbType));
+ break;
+
+ case "mysql":
+ case "mariadb":
+ await client.rollback();
+ break;
+
+ case "mssql":
+ case "oracle":
+ await client.rollback();
+ break;
+ }
+ } catch (rollbackError) {
+ console.error("트랜잭션 롤백 오류:", rollbackError);
+ }
+ }
+
+ throw error;
+ } finally {
+ // 연결 해제
+ if (client) {
+ try {
+ switch (normalizedType) {
+ case "postgresql":
+ client.release();
+ break;
+
+ case "mysql":
+ case "mariadb":
+ client.release();
+ break;
+
+ case "oracle":
+ await client.close();
+ break;
+
+ case "mssql":
+ // MSSQL Transaction 객체는 자동으로 정리됨
+ break;
+ }
+ } catch (releaseError) {
+ console.error("클라이언트 해제 오류:", releaseError);
+ }
+ }
+ }
+}
diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts
new file mode 100644
index 00000000..4d0539b4
--- /dev/null
+++ b/backend-node/src/services/externalRestApiConnectionService.ts
@@ -0,0 +1,669 @@
+import { Pool, QueryResult } from "pg";
+import { getPool } from "../database/db";
+import logger from "../utils/logger";
+import {
+ ExternalRestApiConnection,
+ ExternalRestApiConnectionFilter,
+ RestApiTestRequest,
+ RestApiTestResult,
+ AuthType,
+} from "../types/externalRestApiTypes";
+import { ApiResponse } from "../types/common";
+import crypto from "crypto";
+
+const pool = getPool();
+
+// 암호화 설정
+const ENCRYPTION_KEY =
+ process.env.DB_PASSWORD_SECRET || "default-secret-key-change-in-production";
+const ALGORITHM = "aes-256-gcm";
+
+export class ExternalRestApiConnectionService {
+ /**
+ * REST API 연결 목록 조회
+ */
+ static async getConnections(
+ filter: ExternalRestApiConnectionFilter = {}
+ ): Promise> {
+ try {
+ let query = `
+ SELECT
+ id, connection_name, description, base_url, default_headers,
+ auth_type, auth_config, timeout, retry_count, retry_delay,
+ company_code, is_active, created_date, created_by,
+ updated_date, updated_by, last_test_date, last_test_result, last_test_message
+ FROM external_rest_api_connections
+ WHERE 1=1
+ `;
+
+ const params: any[] = [];
+ let paramIndex = 1;
+
+ // 회사 코드 필터
+ if (filter.company_code) {
+ query += ` AND company_code = $${paramIndex}`;
+ params.push(filter.company_code);
+ paramIndex++;
+ }
+
+ // 활성 상태 필터
+ if (filter.is_active) {
+ query += ` AND is_active = $${paramIndex}`;
+ params.push(filter.is_active);
+ paramIndex++;
+ }
+
+ // 인증 타입 필터
+ if (filter.auth_type) {
+ query += ` AND auth_type = $${paramIndex}`;
+ params.push(filter.auth_type);
+ paramIndex++;
+ }
+
+ // 검색어 필터 (연결명, 설명, URL)
+ if (filter.search) {
+ query += ` AND (
+ connection_name ILIKE $${paramIndex} OR
+ description ILIKE $${paramIndex} OR
+ base_url ILIKE $${paramIndex}
+ )`;
+ params.push(`%${filter.search}%`);
+ paramIndex++;
+ }
+
+ query += ` ORDER BY created_date DESC`;
+
+ const result: QueryResult = await pool.query(query, params);
+
+ // 민감 정보 복호화
+ const connections = result.rows.map((row: any) => ({
+ ...row,
+ auth_config: row.auth_config
+ ? this.decryptSensitiveData(row.auth_config)
+ : null,
+ }));
+
+ return {
+ success: true,
+ data: connections,
+ message: `${connections.length}개의 연결을 조회했습니다.`,
+ };
+ } catch (error) {
+ logger.error("REST API 연결 목록 조회 오류:", error);
+ return {
+ success: false,
+ message: "연결 목록 조회에 실패했습니다.",
+ error: {
+ code: "FETCH_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류",
+ },
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 상세 조회
+ */
+ static async getConnectionById(
+ id: number
+ ): Promise> {
+ try {
+ const query = `
+ SELECT
+ id, connection_name, description, base_url, default_headers,
+ auth_type, auth_config, timeout, retry_count, retry_delay,
+ company_code, is_active, created_date, created_by,
+ updated_date, updated_by, last_test_date, last_test_result, last_test_message
+ FROM external_rest_api_connections
+ WHERE id = $1
+ `;
+
+ const result: QueryResult = await pool.query(query, [id]);
+
+ if (result.rows.length === 0) {
+ return {
+ success: false,
+ message: "연결을 찾을 수 없습니다.",
+ };
+ }
+
+ const connection = result.rows[0];
+ connection.auth_config = connection.auth_config
+ ? this.decryptSensitiveData(connection.auth_config)
+ : null;
+
+ return {
+ success: true,
+ data: connection,
+ message: "연결을 조회했습니다.",
+ };
+ } catch (error) {
+ logger.error("REST API 연결 상세 조회 오류:", error);
+ return {
+ success: false,
+ message: "연결 조회에 실패했습니다.",
+ error: {
+ code: "FETCH_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류",
+ },
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 생성
+ */
+ static async createConnection(
+ data: ExternalRestApiConnection
+ ): Promise> {
+ try {
+ // 유효성 검증
+ this.validateConnectionData(data);
+
+ // 민감 정보 암호화
+ const encryptedAuthConfig = data.auth_config
+ ? this.encryptSensitiveData(data.auth_config)
+ : null;
+
+ const query = `
+ INSERT INTO external_rest_api_connections (
+ connection_name, description, base_url, default_headers,
+ auth_type, auth_config, timeout, retry_count, retry_delay,
+ company_code, is_active, created_by
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ RETURNING *
+ `;
+
+ const params = [
+ data.connection_name,
+ data.description || null,
+ data.base_url,
+ JSON.stringify(data.default_headers || {}),
+ data.auth_type,
+ encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
+ data.timeout || 30000,
+ data.retry_count || 0,
+ data.retry_delay || 1000,
+ data.company_code || "*",
+ data.is_active || "Y",
+ data.created_by || "system",
+ ];
+
+ const result: QueryResult = await pool.query(query, params);
+
+ logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
+
+ return {
+ success: true,
+ data: result.rows[0],
+ message: "연결이 생성되었습니다.",
+ };
+ } catch (error: any) {
+ logger.error("REST API 연결 생성 오류:", error);
+
+ // 중복 키 오류 처리
+ if (error.code === "23505") {
+ return {
+ success: false,
+ message: "이미 존재하는 연결명입니다.",
+ };
+ }
+
+ return {
+ success: false,
+ message: "연결 생성에 실패했습니다.",
+ error: {
+ code: "CREATE_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류",
+ },
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 수정
+ */
+ static async updateConnection(
+ id: number,
+ data: Partial
+ ): Promise> {
+ try {
+ // 기존 연결 확인
+ const existing = await this.getConnectionById(id);
+ if (!existing.success) {
+ return existing;
+ }
+
+ // 민감 정보 암호화
+ const encryptedAuthConfig = data.auth_config
+ ? this.encryptSensitiveData(data.auth_config)
+ : undefined;
+
+ const updateFields: string[] = [];
+ const params: any[] = [];
+ let paramIndex = 1;
+
+ if (data.connection_name !== undefined) {
+ updateFields.push(`connection_name = $${paramIndex}`);
+ params.push(data.connection_name);
+ paramIndex++;
+ }
+
+ if (data.description !== undefined) {
+ updateFields.push(`description = $${paramIndex}`);
+ params.push(data.description);
+ paramIndex++;
+ }
+
+ if (data.base_url !== undefined) {
+ updateFields.push(`base_url = $${paramIndex}`);
+ params.push(data.base_url);
+ paramIndex++;
+ }
+
+ if (data.default_headers !== undefined) {
+ updateFields.push(`default_headers = $${paramIndex}`);
+ params.push(JSON.stringify(data.default_headers));
+ paramIndex++;
+ }
+
+ if (data.auth_type !== undefined) {
+ updateFields.push(`auth_type = $${paramIndex}`);
+ params.push(data.auth_type);
+ paramIndex++;
+ }
+
+ if (encryptedAuthConfig !== undefined) {
+ updateFields.push(`auth_config = $${paramIndex}`);
+ params.push(JSON.stringify(encryptedAuthConfig));
+ paramIndex++;
+ }
+
+ if (data.timeout !== undefined) {
+ updateFields.push(`timeout = $${paramIndex}`);
+ params.push(data.timeout);
+ paramIndex++;
+ }
+
+ if (data.retry_count !== undefined) {
+ updateFields.push(`retry_count = $${paramIndex}`);
+ params.push(data.retry_count);
+ paramIndex++;
+ }
+
+ if (data.retry_delay !== undefined) {
+ updateFields.push(`retry_delay = $${paramIndex}`);
+ params.push(data.retry_delay);
+ paramIndex++;
+ }
+
+ if (data.is_active !== undefined) {
+ updateFields.push(`is_active = $${paramIndex}`);
+ params.push(data.is_active);
+ paramIndex++;
+ }
+
+ if (data.updated_by !== undefined) {
+ updateFields.push(`updated_by = $${paramIndex}`);
+ params.push(data.updated_by);
+ paramIndex++;
+ }
+
+ updateFields.push(`updated_date = NOW()`);
+
+ params.push(id);
+
+ const query = `
+ UPDATE external_rest_api_connections
+ SET ${updateFields.join(", ")}
+ WHERE id = $${paramIndex}
+ RETURNING *
+ `;
+
+ const result: QueryResult = await pool.query(query, params);
+
+ logger.info(`REST API 연결 수정 성공: ID ${id}`);
+
+ return {
+ success: true,
+ data: result.rows[0],
+ message: "연결이 수정되었습니다.",
+ };
+ } catch (error: any) {
+ logger.error("REST API 연결 수정 오류:", error);
+
+ if (error.code === "23505") {
+ return {
+ success: false,
+ message: "이미 존재하는 연결명입니다.",
+ };
+ }
+
+ return {
+ success: false,
+ message: "연결 수정에 실패했습니다.",
+ error: {
+ code: "UPDATE_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류",
+ },
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 삭제
+ */
+ static async deleteConnection(id: number): Promise> {
+ try {
+ const query = `
+ DELETE FROM external_rest_api_connections
+ WHERE id = $1
+ RETURNING connection_name
+ `;
+
+ const result: QueryResult = await pool.query(query, [id]);
+
+ if (result.rows.length === 0) {
+ return {
+ success: false,
+ message: "연결을 찾을 수 없습니다.",
+ };
+ }
+
+ logger.info(`REST API 연결 삭제 성공: ${result.rows[0].connection_name}`);
+
+ return {
+ success: true,
+ message: "연결이 삭제되었습니다.",
+ };
+ } catch (error) {
+ logger.error("REST API 연결 삭제 오류:", error);
+ return {
+ success: false,
+ message: "연결 삭제에 실패했습니다.",
+ error: {
+ code: "DELETE_ERROR",
+ details: error instanceof Error ? error.message : "알 수 없는 오류",
+ },
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 테스트 (테스트 요청 데이터 기반)
+ */
+ static async testConnection(
+ testRequest: RestApiTestRequest
+ ): Promise {
+ const startTime = Date.now();
+
+ try {
+ // 헤더 구성
+ const headers = { ...testRequest.headers };
+
+ // 인증 헤더 추가
+ if (
+ testRequest.auth_type === "bearer" &&
+ testRequest.auth_config?.token
+ ) {
+ headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`;
+ } else if (testRequest.auth_type === "basic" && testRequest.auth_config) {
+ const credentials = Buffer.from(
+ `${testRequest.auth_config.username}:${testRequest.auth_config.password}`
+ ).toString("base64");
+ headers["Authorization"] = `Basic ${credentials}`;
+ } else if (
+ testRequest.auth_type === "api-key" &&
+ testRequest.auth_config
+ ) {
+ if (testRequest.auth_config.keyLocation === "header") {
+ headers[testRequest.auth_config.keyName] =
+ testRequest.auth_config.keyValue;
+ }
+ }
+
+ // URL 구성
+ let url = testRequest.base_url;
+ if (testRequest.endpoint) {
+ url = testRequest.endpoint.startsWith("/")
+ ? `${testRequest.base_url}${testRequest.endpoint}`
+ : `${testRequest.base_url}/${testRequest.endpoint}`;
+ }
+
+ // API Key가 쿼리에 있는 경우
+ if (
+ testRequest.auth_type === "api-key" &&
+ testRequest.auth_config?.keyLocation === "query" &&
+ testRequest.auth_config?.keyName &&
+ testRequest.auth_config?.keyValue
+ ) {
+ const separator = url.includes("?") ? "&" : "?";
+ url = `${url}${separator}${testRequest.auth_config.keyName}=${testRequest.auth_config.keyValue}`;
+ }
+
+ logger.info(
+ `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
+ );
+
+ // HTTP 요청 실행
+ const response = await fetch(url, {
+ method: testRequest.method || "GET",
+ headers,
+ signal: AbortSignal.timeout(testRequest.timeout || 30000),
+ });
+
+ const responseTime = Date.now() - startTime;
+ let responseData = null;
+
+ try {
+ responseData = await response.json();
+ } catch {
+ // JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
+ }
+
+ return {
+ success: response.ok,
+ message: response.ok
+ ? "연결 성공"
+ : `연결 실패 (${response.status} ${response.statusText})`,
+ response_time: responseTime,
+ status_code: response.status,
+ response_data: responseData,
+ };
+ } catch (error) {
+ const responseTime = Date.now() - startTime;
+
+ logger.error("REST API 연결 테스트 오류:", error);
+
+ return {
+ success: false,
+ message: "연결 실패",
+ response_time: responseTime,
+ error_details:
+ error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+ }
+
+ /**
+ * REST API 연결 테스트 (ID 기반)
+ */
+ static async testConnectionById(
+ id: number,
+ endpoint?: string
+ ): Promise {
+ try {
+ const connectionResult = await this.getConnectionById(id);
+
+ if (!connectionResult.success || !connectionResult.data) {
+ return {
+ success: false,
+ message: "연결을 찾을 수 없습니다.",
+ };
+ }
+
+ const connection = connectionResult.data;
+
+ const testRequest: RestApiTestRequest = {
+ id: connection.id,
+ base_url: connection.base_url,
+ endpoint,
+ headers: connection.default_headers,
+ auth_type: connection.auth_type,
+ auth_config: connection.auth_config,
+ timeout: connection.timeout,
+ };
+
+ const result = await this.testConnection(testRequest);
+
+ // 테스트 결과 저장
+ await pool.query(
+ `
+ UPDATE external_rest_api_connections
+ SET
+ last_test_date = NOW(),
+ last_test_result = $1,
+ last_test_message = $2
+ WHERE id = $3
+ `,
+ [result.success ? "Y" : "N", result.message, id]
+ );
+
+ return result;
+ } catch (error) {
+ logger.error("REST API 연결 테스트 (ID) 오류:", error);
+ return {
+ success: false,
+ message: "연결 테스트에 실패했습니다.",
+ error_details:
+ error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+ }
+
+ /**
+ * 민감 정보 암호화
+ */
+ private static encryptSensitiveData(authConfig: any): any {
+ if (!authConfig) return null;
+
+ const encrypted = { ...authConfig };
+
+ // 암호화 대상 필드
+ if (encrypted.keyValue) {
+ encrypted.keyValue = this.encrypt(encrypted.keyValue);
+ }
+ if (encrypted.token) {
+ encrypted.token = this.encrypt(encrypted.token);
+ }
+ if (encrypted.password) {
+ encrypted.password = this.encrypt(encrypted.password);
+ }
+ if (encrypted.clientSecret) {
+ encrypted.clientSecret = this.encrypt(encrypted.clientSecret);
+ }
+
+ return encrypted;
+ }
+
+ /**
+ * 민감 정보 복호화
+ */
+ private static decryptSensitiveData(authConfig: any): any {
+ if (!authConfig) return null;
+
+ const decrypted = { ...authConfig };
+
+ // 복호화 대상 필드
+ try {
+ if (decrypted.keyValue) {
+ decrypted.keyValue = this.decrypt(decrypted.keyValue);
+ }
+ if (decrypted.token) {
+ decrypted.token = this.decrypt(decrypted.token);
+ }
+ if (decrypted.password) {
+ decrypted.password = this.decrypt(decrypted.password);
+ }
+ if (decrypted.clientSecret) {
+ decrypted.clientSecret = this.decrypt(decrypted.clientSecret);
+ }
+ } catch (error) {
+ logger.warn("민감 정보 복호화 실패 (암호화되지 않은 데이터일 수 있음)");
+ }
+
+ return decrypted;
+ }
+
+ /**
+ * 암호화 헬퍼
+ */
+ private static encrypt(text: string): string {
+ const iv = crypto.randomBytes(16);
+ const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+
+ let encrypted = cipher.update(text, "utf8", "hex");
+ encrypted += cipher.final("hex");
+
+ const authTag = cipher.getAuthTag();
+
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
+ }
+
+ /**
+ * 복호화 헬퍼
+ */
+ private static decrypt(text: string): string {
+ const parts = text.split(":");
+ if (parts.length !== 3) {
+ // 암호화되지 않은 데이터
+ return text;
+ }
+
+ const iv = Buffer.from(parts[0], "hex");
+ const authTag = Buffer.from(parts[1], "hex");
+ const encryptedText = parts[2];
+
+ const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
+ decipher.setAuthTag(authTag);
+
+ let decrypted = decipher.update(encryptedText, "hex", "utf8");
+ decrypted += decipher.final("utf8");
+
+ return decrypted;
+ }
+
+ /**
+ * 연결 데이터 유효성 검증
+ */
+ private static validateConnectionData(data: ExternalRestApiConnection): void {
+ if (!data.connection_name || data.connection_name.trim() === "") {
+ throw new Error("연결명은 필수입니다.");
+ }
+
+ if (!data.base_url || data.base_url.trim() === "") {
+ throw new Error("기본 URL은 필수입니다.");
+ }
+
+ // URL 형식 검증
+ try {
+ new URL(data.base_url);
+ } catch {
+ throw new Error("올바른 URL 형식이 아닙니다.");
+ }
+
+ // 인증 타입 검증
+ const validAuthTypes: AuthType[] = [
+ "none",
+ "api-key",
+ "bearer",
+ "basic",
+ "oauth2",
+ ];
+ if (!validAuthTypes.includes(data.auth_type)) {
+ throw new Error("올바르지 않은 인증 타입입니다.");
+ }
+ }
+}
diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts
index 9ed99548..39ab6013 100644
--- a/backend-node/src/services/flowDataMoveService.ts
+++ b/backend-node/src/services/flowDataMoveService.ts
@@ -6,10 +6,25 @@
*/
import db from "../database/db";
-import { FlowAuditLog, FlowIntegrationContext } from "../types/flow";
+import {
+ FlowAuditLog,
+ FlowIntegrationContext,
+ FlowDefinition,
+} from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService";
import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService";
+import {
+ getExternalPool,
+ executeExternalQuery,
+ executeExternalTransaction,
+} from "./externalDbHelper";
+import {
+ getPlaceholder,
+ buildUpdateQuery,
+ buildInsertQuery,
+ buildSelectQuery,
+} from "./dbQueryBuilder";
export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService;
@@ -33,6 +48,28 @@ export class FlowDataMoveService {
userId: string = "system",
additionalData?: Record
): Promise<{ success: boolean; targetDataId?: any; message?: string }> {
+ // 0. 플로우 정의 조회 (DB 소스 확인)
+ const flowDefinition = await this.flowDefinitionService.findById(flowId);
+ if (!flowDefinition) {
+ throw new Error(`플로우를 찾을 수 없습니다 (ID: ${flowId})`);
+ }
+
+ // 외부 DB인 경우 별도 처리
+ if (
+ flowDefinition.dbSourceType === "external" &&
+ flowDefinition.dbConnectionId
+ ) {
+ return await this.moveDataToStepExternal(
+ flowDefinition.dbConnectionId,
+ fromStepId,
+ toStepId,
+ dataId,
+ userId,
+ additionalData
+ );
+ }
+
+ // 내부 DB 처리 (기존 로직)
return await db.transaction(async (client) => {
try {
// 1. 단계 정보 조회
@@ -124,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,
@@ -136,6 +195,11 @@ export class FlowDataMoveService {
statusFrom: fromStep.statusValue,
statusTo: toStep.statusValue,
userId,
+ dbConnectionId:
+ flowDefinition.dbSourceType === "external"
+ ? flowDefinition.dbConnectionId
+ : null,
+ dbConnectionName,
});
return {
@@ -160,7 +224,14 @@ export class FlowDataMoveService {
dataId: any,
additionalData?: Record
): Promise {
- const statusColumn = toStep.statusColumn || "flow_status";
+ // 상태 컬럼이 지정되지 않은 경우 에러
+ if (!toStep.statusColumn) {
+ throw new Error(
+ `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.`
+ );
+ }
+
+ const statusColumn = toStep.statusColumn;
const tableName = fromStep.tableName;
// 추가 필드 업데이트 준비
@@ -317,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, [
@@ -334,6 +406,8 @@ export class FlowDataMoveService {
params.statusTo,
params.userId,
params.note || null,
+ params.dbConnectionId || null,
+ params.dbConnectionName || null,
]);
}
@@ -408,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,
}));
}
@@ -452,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,
}));
}
@@ -590,4 +668,324 @@ export class FlowDataMoveService {
userId,
]);
}
+
+ /**
+ * 외부 DB 데이터 이동 처리
+ */
+ private async moveDataToStepExternal(
+ dbConnectionId: number,
+ fromStepId: number,
+ toStepId: number,
+ dataId: any,
+ userId: string = "system",
+ additionalData?: Record
+ ): Promise<{ success: boolean; targetDataId?: any; message?: string }> {
+ return await executeExternalTransaction(
+ dbConnectionId,
+ async (externalClient, dbType) => {
+ try {
+ // 1. 단계 정보 조회 (내부 DB에서)
+ const fromStep = await this.flowStepService.findById(fromStepId);
+ const toStep = await this.flowStepService.findById(toStepId);
+
+ if (!fromStep || !toStep) {
+ throw new Error("유효하지 않은 단계입니다");
+ }
+
+ let targetDataId = dataId;
+ let sourceTable = fromStep.tableName;
+ let targetTable = toStep.tableName || fromStep.tableName;
+
+ // 2. 이동 방식에 따라 처리
+ switch (toStep.moveType || "status") {
+ case "status":
+ // 상태 변경 방식
+ await this.moveByStatusChangeExternal(
+ externalClient,
+ dbType,
+ fromStep,
+ toStep,
+ dataId,
+ additionalData
+ );
+ break;
+
+ case "table":
+ // 테이블 이동 방식
+ targetDataId = await this.moveByTableTransferExternal(
+ externalClient,
+ dbType,
+ fromStep,
+ toStep,
+ dataId,
+ additionalData
+ );
+ targetTable = toStep.targetTable || toStep.tableName;
+ break;
+
+ case "both":
+ // 하이브리드 방식: 둘 다 수행
+ await this.moveByStatusChangeExternal(
+ externalClient,
+ dbType,
+ fromStep,
+ toStep,
+ dataId,
+ additionalData
+ );
+ targetDataId = await this.moveByTableTransferExternal(
+ externalClient,
+ dbType,
+ fromStep,
+ toStep,
+ dataId,
+ additionalData
+ );
+ targetTable = toStep.targetTable || toStep.tableName;
+ break;
+
+ default:
+ throw new Error(
+ `지원하지 않는 이동 방식입니다: ${toStep.moveType}`
+ );
+ }
+
+ // 3. 외부 연동 처리는 생략 (외부 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 (
+ flow_definition_id, from_step_id, to_step_id,
+ move_type, source_table, target_table,
+ source_data_id, target_data_id,
+ status_from, status_to,
+ 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, [
+ toStep.flowDefinitionId,
+ fromStep.id,
+ toStep.id,
+ toStep.moveType || "status",
+ sourceTable,
+ targetTable,
+ dataId,
+ targetDataId,
+ null, // statusFrom
+ toStep.statusValue || null, // statusTo
+ userId,
+ `외부 DB (${dbType}) 데이터 이동`,
+ dbConnectionId,
+ dbConnectionName,
+ ]);
+
+ return {
+ success: true,
+ targetDataId,
+ message: `데이터 이동이 완료되었습니다 (외부 DB: ${dbType})`,
+ };
+ } catch (error: any) {
+ console.error("외부 DB 데이터 이동 오류:", error);
+ throw error;
+ }
+ }
+ );
+ }
+
+ /**
+ * 외부 DB 상태 변경 방식으로 데이터 이동
+ */
+ private async moveByStatusChangeExternal(
+ externalClient: any,
+ dbType: string,
+ fromStep: any,
+ toStep: any,
+ dataId: any,
+ additionalData?: Record
+ ): Promise {
+ // 상태 컬럼이 지정되지 않은 경우 에러
+ if (!toStep.statusColumn) {
+ throw new Error(
+ `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.`
+ );
+ }
+
+ const statusColumn = toStep.statusColumn;
+ const tableName = fromStep.tableName;
+ const normalizedDbType = dbType.toLowerCase();
+
+ // 업데이트할 필드 준비
+ const updateFields: { column: string; value: any }[] = [
+ { column: statusColumn, value: toStep.statusValue },
+ ];
+
+ // 추가 데이터가 있으면 함께 업데이트
+ if (additionalData) {
+ for (const [key, value] of Object.entries(additionalData)) {
+ updateFields.push({ column: key, value });
+ }
+ }
+
+ // DB별 쿼리 생성
+ const { query: updateQuery, values } = buildUpdateQuery(
+ dbType,
+ tableName,
+ updateFields,
+ "id"
+ );
+
+ // WHERE 절 값 설정 (마지막 파라미터)
+ values[values.length - 1] = dataId;
+
+ // 쿼리 실행 (DB 타입별 처리)
+ let result: any;
+ if (normalizedDbType === "postgresql") {
+ result = await externalClient.query(updateQuery, values);
+ } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") {
+ [result] = await externalClient.query(updateQuery, values);
+ } else if (normalizedDbType === "mssql") {
+ const request = externalClient.request();
+ values.forEach((val: any, idx: number) => {
+ request.input(`p${idx + 1}`, val);
+ });
+ result = await request.query(updateQuery);
+ } else if (normalizedDbType === "oracle") {
+ result = await externalClient.execute(updateQuery, values, {
+ autoCommit: false,
+ });
+ }
+
+ // 결과 확인
+ const affectedRows =
+ normalizedDbType === "postgresql"
+ ? result.rowCount
+ : normalizedDbType === "mssql"
+ ? result.rowsAffected[0]
+ : normalizedDbType === "oracle"
+ ? result.rowsAffected
+ : result.affectedRows;
+
+ if (affectedRows === 0) {
+ throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`);
+ }
+ }
+
+ /**
+ * 외부 DB 테이블 이동 방식으로 데이터 이동
+ */
+ private async moveByTableTransferExternal(
+ externalClient: any,
+ dbType: string,
+ fromStep: any,
+ toStep: any,
+ dataId: any,
+ additionalData?: Record
+ ): Promise {
+ const sourceTable = fromStep.tableName;
+ const targetTable = toStep.targetTable || toStep.tableName;
+ const fieldMappings = toStep.fieldMappings || {};
+ const normalizedDbType = dbType.toLowerCase();
+
+ // 1. 소스 데이터 조회
+ const { query: selectQuery, placeholder } = buildSelectQuery(
+ dbType,
+ sourceTable,
+ "id"
+ );
+
+ let sourceResult: any;
+ if (normalizedDbType === "postgresql") {
+ sourceResult = await externalClient.query(selectQuery, [dataId]);
+ } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") {
+ [sourceResult] = await externalClient.query(selectQuery, [dataId]);
+ } else if (normalizedDbType === "mssql") {
+ const request = externalClient.request();
+ request.input("p1", dataId);
+ sourceResult = await request.query(selectQuery);
+ sourceResult = { rows: sourceResult.recordset };
+ } else if (normalizedDbType === "oracle") {
+ sourceResult = await externalClient.execute(selectQuery, [dataId], {
+ autoCommit: false,
+ outFormat: 4001, // oracledb.OUT_FORMAT_OBJECT
+ });
+ }
+
+ const rows = sourceResult.rows || sourceResult;
+ if (!rows || rows.length === 0) {
+ throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
+ }
+
+ const sourceData = rows[0];
+
+ // 2. 필드 매핑 적용
+ const targetData: Record = {};
+
+ for (const [targetField, sourceField] of Object.entries(fieldMappings)) {
+ const sourceFieldKey = sourceField as string;
+ if (sourceData[sourceFieldKey] !== undefined) {
+ targetData[targetField] = sourceData[sourceFieldKey];
+ }
+ }
+
+ // 추가 데이터 병합
+ if (additionalData) {
+ Object.assign(targetData, additionalData);
+ }
+
+ // 3. 대상 테이블에 삽입
+ const { query: insertQuery, values } = buildInsertQuery(
+ dbType,
+ targetTable,
+ targetData
+ );
+
+ let insertResult: any;
+ let newDataId: any;
+
+ if (normalizedDbType === "postgresql") {
+ insertResult = await externalClient.query(insertQuery, values);
+ newDataId = insertResult.rows[0].id;
+ } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") {
+ [insertResult] = await externalClient.query(insertQuery, values);
+ newDataId = insertResult.insertId;
+ } else if (normalizedDbType === "mssql") {
+ const request = externalClient.request();
+ values.forEach((val: any, idx: number) => {
+ request.input(`p${idx + 1}`, val);
+ });
+ insertResult = await request.query(insertQuery);
+ newDataId = insertResult.recordset[0].id;
+ } else if (normalizedDbType === "oracle") {
+ // Oracle RETURNING 절 처리
+ const outBinds: any = { id: { dir: 3003, type: 2001 } }; // OUT, NUMBER
+ insertResult = await externalClient.execute(insertQuery, values, {
+ autoCommit: false,
+ outBinds: outBinds,
+ });
+ newDataId = insertResult.outBinds.id[0];
+ }
+
+ // 4. 필요 시 소스 데이터 삭제 (옵션)
+ // const deletePlaceholder = getPlaceholder(dbType, 1);
+ // await externalClient.query(`DELETE FROM ${sourceTable} WHERE id = ${deletePlaceholder}`, [dataId]);
+
+ return newDataId;
+ }
}
diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts
index 859e0792..f08f934d 100644
--- a/backend-node/src/services/flowDefinitionService.ts
+++ b/backend-node/src/services/flowDefinitionService.ts
@@ -17,18 +17,33 @@ export class FlowDefinitionService {
request: CreateFlowDefinitionRequest,
userId: string
): Promise {
+ console.log("🔥 flowDefinitionService.create called with:", {
+ name: request.name,
+ description: request.description,
+ tableName: request.tableName,
+ dbSourceType: request.dbSourceType,
+ dbConnectionId: request.dbConnectionId,
+ userId,
+ });
+
const query = `
- INSERT INTO flow_definition (name, description, table_name, created_by)
- VALUES ($1, $2, $3, $4)
+ INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
- const result = await db.query(query, [
+ const values = [
request.name,
request.description || null,
- request.tableName,
+ request.tableName || null,
+ request.dbSourceType || "internal",
+ request.dbConnectionId || null,
userId,
- ]);
+ ];
+
+ console.log("💾 Executing INSERT with values:", values);
+
+ const result = await db.query(query, values);
return this.mapToFlowDefinition(result[0]);
}
@@ -162,6 +177,8 @@ export class FlowDefinitionService {
name: row.name,
description: row.description,
tableName: row.table_name,
+ dbSourceType: row.db_source_type || "internal",
+ dbConnectionId: row.db_connection_id,
isActive: row.is_active,
createdBy: row.created_by,
createdAt: row.created_at,
diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts
index ae4f1369..9d9eb9c4 100644
--- a/backend-node/src/services/flowExecutionService.ts
+++ b/backend-node/src/services/flowExecutionService.ts
@@ -8,6 +8,8 @@ import { FlowStepDataCount, FlowStepDataList } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser";
+import { executeExternalQuery } from "./externalDbHelper";
+import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService;
@@ -28,6 +30,13 @@ export class FlowExecutionService {
throw new Error(`Flow definition not found: ${flowId}`);
}
+ console.log("🔍 [getStepDataCount] Flow Definition:", {
+ flowId,
+ dbSourceType: flowDef.dbSourceType,
+ dbConnectionId: flowDef.dbConnectionId,
+ tableName: flowDef.tableName,
+ });
+
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
@@ -46,11 +55,40 @@ export class FlowExecutionService {
step.conditionJson
);
- // 5. 카운트 쿼리 실행
+ // 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
- const result = await db.query(query, params);
- return parseInt(result[0].count);
+ console.log("🔍 [getStepDataCount] Query Info:", {
+ tableName,
+ query,
+ params,
+ isExternal: flowDef.dbSourceType === "external",
+ connectionId: flowDef.dbConnectionId,
+ });
+
+ let result: any;
+ if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
+ // 외부 DB 조회
+ console.log(
+ "✅ [getStepDataCount] Using EXTERNAL DB:",
+ flowDef.dbConnectionId
+ );
+ const externalResult = await executeExternalQuery(
+ flowDef.dbConnectionId,
+ query,
+ params
+ );
+ console.log("📦 [getStepDataCount] External result:", externalResult);
+ result = externalResult.rows;
+ } else {
+ // 내부 DB 조회
+ console.log("✅ [getStepDataCount] Using INTERNAL DB");
+ result = await db.query(query, params);
+ }
+
+ const count = parseInt(result[0].count || result[0].COUNT);
+ console.log("✅ [getStepDataCount] Final count:", count);
+ return count;
}
/**
@@ -88,47 +126,98 @@ export class FlowExecutionService {
const offset = (page - 1) * pageSize;
+ const isExternalDb =
+ flowDef.dbSourceType === "external" && flowDef.dbConnectionId;
+
// 5. 전체 카운트
const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
- const countResult = await db.query(countQuery, params);
- const total = parseInt(countResult[0].count);
+ let countResult: any;
+ let total: number;
- // 6. 테이블의 Primary Key 컬럼 찾기
- let orderByColumn = "";
- try {
- const pkQuery = `
- SELECT a.attname
- FROM pg_index i
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
- WHERE i.indrelid = $1::regclass
- AND i.indisprimary
- LIMIT 1
- `;
- const pkResult = await db.query(pkQuery, [tableName]);
- if (pkResult.length > 0) {
- orderByColumn = pkResult[0].attname;
- }
- } catch (err) {
- // Primary Key를 찾지 못하면 ORDER BY 없이 진행
- console.warn(`Could not find primary key for table ${tableName}:`, err);
+ if (isExternalDb) {
+ const externalCountResult = await executeExternalQuery(
+ flowDef.dbConnectionId!,
+ countQuery,
+ params
+ );
+ countResult = externalCountResult.rows;
+ total = parseInt(countResult[0].count || countResult[0].COUNT);
+ } else {
+ countResult = await db.query(countQuery, params);
+ total = parseInt(countResult[0].count);
}
- // 7. 데이터 조회
- const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : "";
- const dataQuery = `
- SELECT * FROM ${tableName}
- WHERE ${where}
- ${orderByClause}
- LIMIT $${params.length + 1} OFFSET $${params.length + 2}
- `;
- const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);
+ // 6. 데이터 조회 (DB 타입별 페이징 처리)
+ let dataQuery: string;
+ let dataParams: any[];
- return {
- records: dataResult,
- total,
- page,
- pageSize,
- };
+ if (isExternalDb) {
+ // 외부 DB는 id 컬럼으로 정렬 (가정)
+ // DB 타입에 따른 페이징 절은 빌더에서 처리하지 않고 직접 작성
+ // PostgreSQL, MySQL, MSSQL, Oracle 모두 지원하도록 단순화
+ dataQuery = `
+ SELECT * FROM ${tableName}
+ WHERE ${where}
+ ORDER BY id DESC
+ LIMIT ${pageSize} OFFSET ${offset}
+ `;
+ dataParams = params;
+
+ const externalDataResult = await executeExternalQuery(
+ flowDef.dbConnectionId!,
+ dataQuery,
+ dataParams
+ );
+
+ return {
+ records: externalDataResult.rows,
+ total,
+ page,
+ pageSize,
+ };
+ } else {
+ // 내부 DB (PostgreSQL)
+ // Primary Key 컬럼 찾기
+ let orderByColumn = "";
+ try {
+ const pkQuery = `
+ SELECT a.attname
+ FROM pg_index i
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
+ WHERE i.indrelid = $1::regclass
+ AND i.indisprimary
+ LIMIT 1
+ `;
+ const pkResult = await db.query(pkQuery, [tableName]);
+ if (pkResult.length > 0) {
+ orderByColumn = pkResult[0].attname;
+ }
+ } catch (err) {
+ console.warn(`Could not find primary key for table ${tableName}:`, err);
+ }
+
+ const orderByClause = orderByColumn
+ ? `ORDER BY ${orderByColumn} DESC`
+ : "";
+ dataQuery = `
+ SELECT * FROM ${tableName}
+ WHERE ${where}
+ ${orderByClause}
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}
+ `;
+ const dataResult = await db.query(dataQuery, [
+ ...params,
+ pageSize,
+ offset,
+ ]);
+
+ return {
+ records: dataResult,
+ total,
+ page,
+ pageSize,
+ };
+ }
}
/**
diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts
index d5e3a78f..6bec4d93 100644
--- a/backend-node/src/services/mailReceiveBasicService.ts
+++ b/backend-node/src/services/mailReceiveBasicService.ts
@@ -88,6 +88,9 @@ export class MailReceiveBasicService {
port: config.port,
tls: config.tls,
tlsOptions: { rejectUnauthorized: false },
+ authTimeout: 30000, // 인증 타임아웃 30초
+ connTimeout: 30000, // 연결 타임아웃 30초
+ keepalive: true,
});
}
@@ -116,7 +119,7 @@ export class MailReceiveBasicService {
tls: true,
};
- // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
+ // // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
@@ -130,7 +133,7 @@ export class MailReceiveBasicService {
}, 30000);
imap.once("ready", () => {
- // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
+ // // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout);
imap.openBox("INBOX", true, (err: any, box: any) => {
@@ -140,10 +143,10 @@ export class MailReceiveBasicService {
return reject(err);
}
- // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
+ // // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total;
if (totalMessages === 0) {
- // console.log('📭 메일함이 비어있습니다');
+ // // console.log('📭 메일함이 비어있습니다');
imap.end();
return resolve([]);
}
@@ -152,19 +155,19 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages;
- // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
+ // // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ["HEADER", "TEXT"],
struct: true,
});
- // console.log(`📦 fetch 객체 생성 완료`);
+ // // console.log(`📦 fetch 객체 생성 완료`);
let processedCount = 0;
const totalToProcess = end - start + 1;
fetch.on("message", (msg: any, seqno: any) => {
- // console.log(`📬 메일 #${seqno} 처리 시작`);
+ // // console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = "";
let body: string = "";
let attributes: any = null;
@@ -222,7 +225,7 @@ export class MailReceiveBasicService {
};
mails.push(mail);
- // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
+ // // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
processedCount++;
} catch (parseError) {
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
@@ -240,18 +243,18 @@ export class MailReceiveBasicService {
});
fetch.once("end", () => {
- // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
+ // // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
// 모든 메일 처리가 완료될 때까지 대기
const checkComplete = setInterval(() => {
- // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
+ // // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
if (processedCount >= totalToProcess) {
clearInterval(checkComplete);
- // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
+ // // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
imap.end();
// 최신 메일이 위로 오도록 정렬
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
- // console.log(`📤 메일 목록 반환: ${mails.length}개`);
+ // // console.log(`📤 메일 목록 반환: ${mails.length}개`);
resolve(mails);
}
}, 100);
@@ -259,7 +262,7 @@ export class MailReceiveBasicService {
// 최대 10초 대기
setTimeout(() => {
clearInterval(checkComplete);
- // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
+ // // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
imap.end();
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
resolve(mails);
@@ -275,10 +278,10 @@ export class MailReceiveBasicService {
});
imap.once("end", () => {
- // console.log('🔌 IMAP 연결 종료');
+ // // console.log('🔌 IMAP 연결 종료');
});
- // console.log('🔗 IMAP.connect() 호출...');
+ // // console.log('🔗 IMAP.connect() 호출...');
imap.connect();
});
}
@@ -329,9 +332,9 @@ export class MailReceiveBasicService {
return reject(err);
}
- console.log(
- `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
- );
+ // console.log(
+ // `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
+ // );
if (seqno > box.messages.total || seqno < 1) {
console.error(
@@ -350,21 +353,21 @@ export class MailReceiveBasicService {
let parsingComplete = false;
fetch.on("message", (msg: any, seqnum: any) => {
- console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
+ // console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on("body", (stream: any, info: any) => {
- console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
+ // console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
});
stream.once("end", async () => {
- console.log(
- `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
- );
+ // console.log(
+ // `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
+ // );
try {
const parsed = await simpleParser(buffer);
- console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
+ // console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
const fromAddress = Array.isArray(parsed.from)
? parsed.from[0]
@@ -412,7 +415,7 @@ export class MailReceiveBasicService {
// msg 전체가 처리되었을 때 이벤트
msg.once("end", () => {
- console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
+ // console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
});
});
@@ -423,15 +426,15 @@ export class MailReceiveBasicService {
});
fetch.once("end", () => {
- console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
+ // console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
// 비동기 파싱이 완료될 때까지 대기
const waitForParsing = setInterval(() => {
if (parsingComplete) {
clearInterval(waitForParsing);
- console.log(
- `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
- );
+ // console.log(
+ // `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
+ // );
imap.end();
resolve(mailDetail);
}
@@ -474,29 +477,47 @@ export class MailReceiveBasicService {
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any;
+ const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
+
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
- port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
- tls: true,
+ port: imapPort,
+ tls: imapPort === 993, // 993 포트면 TLS 사용
};
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
+ // 타임아웃 설정
+ const timeout = setTimeout(() => {
+ console.error('❌ IMAP 읽음 표시 타임아웃 (30초)');
+ imap.end();
+ reject(new Error("IMAP 연결 타임아웃"));
+ }, 30000);
+
imap.once("ready", () => {
+ clearTimeout(timeout);
+ // console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`);
+
+ // false로 변경: 쓰기 가능 모드로 INBOX 열기
imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) {
+ console.error('❌ INBOX 열기 실패:', err);
imap.end();
return reject(err);
}
+ // console.log(`📬 INBOX 열림 (쓰기 가능 모드)`);
+
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
imap.end();
if (flagErr) {
+ console.error("❌ 읽음 플래그 설정 실패:", flagErr);
reject(flagErr);
} else {
+ // console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno);
resolve({
success: true,
message: "메일을 읽음으로 표시했습니다.",
@@ -507,9 +528,16 @@ export class MailReceiveBasicService {
});
imap.once("error", (imapErr: any) => {
+ clearTimeout(timeout);
+ console.error('❌ IMAP 에러:', imapErr);
reject(imapErr);
});
+ imap.once("end", () => {
+ clearTimeout(timeout);
+ });
+
+ // console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`);
imap.connect();
});
}
@@ -528,7 +556,7 @@ export class MailReceiveBasicService {
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
- // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
+ // // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
const accountAny = account as any;
const imapConfig: ImapConfig = {
@@ -538,7 +566,7 @@ export class MailReceiveBasicService {
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
- // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
+ // // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
@@ -664,32 +692,32 @@ export class MailReceiveBasicService {
let parsingComplete = false;
fetch.on("message", (msg: any, seqnum: any) => {
- console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
+ // console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on("body", (stream: any, info: any) => {
- console.log(`📎 메일 본문 스트림 시작`);
+ // console.log(`📎 메일 본문 스트림 시작`);
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
});
stream.once("end", async () => {
- console.log(
- `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
- );
+ // console.log(
+ // `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
+ // );
try {
const parsed = await simpleParser(buffer);
- console.log(
- `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
- );
+ // console.log(
+ // `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
+ // );
if (
parsed.attachments &&
parsed.attachments[attachmentIndex]
) {
const attachment = parsed.attachments[attachmentIndex];
- console.log(
- `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
- );
+ // console.log(
+ // `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
+ // );
// 안전한 파일명 생성
const safeFilename = this.sanitizeFilename(
@@ -701,7 +729,7 @@ export class MailReceiveBasicService {
// 파일 저장
await fs.writeFile(filePath, attachment.content);
- console.log(`📎 파일 저장 완료: ${filePath}`);
+ // console.log(`📎 파일 저장 완료: ${filePath}`);
attachmentResult = {
filePath,
@@ -711,9 +739,9 @@ export class MailReceiveBasicService {
};
parsingComplete = true;
} else {
- console.log(
- `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
- );
+ // console.log(
+ // `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
+ // );
parsingComplete = true;
}
} catch (parseError) {
@@ -731,14 +759,14 @@ export class MailReceiveBasicService {
});
fetch.once("end", () => {
- console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
+ // console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
// 파싱 완료를 기다림 (최대 5초)
const checkComplete = setInterval(() => {
if (parsingComplete) {
- console.log(
- `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
- );
+ // console.log(
+ // `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
+ // );
clearInterval(checkComplete);
imap.end();
resolve(attachmentResult);
@@ -747,9 +775,9 @@ export class MailReceiveBasicService {
setTimeout(() => {
clearInterval(checkComplete);
- console.log(
- `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
- );
+ // console.log(
+ // `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
+ // );
imap.end();
resolve(attachmentResult);
}, 5000);
@@ -774,4 +802,96 @@ export class MailReceiveBasicService {
.replace(/_{2,}/g, "_")
.substring(0, 200); // 최대 길이 제한
}
+
+ /**
+ * IMAP 서버에서 메일 삭제 (휴지통으로 이동)
+ */
+ async deleteMail(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
+ const account = await mailAccountFileService.getAccountById(accountId);
+
+ if (!account) {
+ throw new Error("메일 계정을 찾을 수 없습니다.");
+ }
+
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ // IMAP 설정 (타입 캐스팅)
+ const accountAny = account as any;
+ const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
+
+ const config: ImapConfig = {
+ user: account.smtpUsername || account.email,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: imapPort,
+ tls: imapPort === 993, // 993 포트면 TLS 사용, 143이면 사용 안함
+ };
+
+ return new Promise((resolve, reject) => {
+ const imap = this.createImapConnection(config);
+
+ // 30초 타임아웃 설정
+ const timeout = setTimeout(() => {
+ console.error('❌ IMAP 메일 삭제 타임아웃 (30초)');
+ imap.end();
+ reject(new Error("IMAP 연결 타임아웃"));
+ }, 30000);
+
+ imap.once("ready", () => {
+ clearTimeout(timeout);
+ // console.log(`🔗 IMAP 연결 성공 - 메일 삭제 시작 (seqno=${seqno})`);
+
+ imap.openBox("INBOX", false, (err: any) => {
+ if (err) {
+ console.error('❌ INBOX 열기 실패:', err);
+ imap.end();
+ return reject(err);
+ }
+
+ // 메일을 삭제 플래그로 표시 (seq.addFlags 사용)
+ imap.seq.addFlags(seqno, ["\\Deleted"], (flagErr: any) => {
+ if (flagErr) {
+ console.error('❌ 삭제 플래그 추가 실패:', flagErr);
+ imap.end();
+ return reject(flagErr);
+ }
+
+ // console.log(`✓ 삭제 플래그 추가 완료 (seqno=${seqno})`);
+
+ // 삭제 플래그가 표시된 메일을 영구 삭제 (실제로는 휴지통으로 이동)
+ imap.expunge((expungeErr: any) => {
+ imap.end();
+
+ if (expungeErr) {
+ console.error('❌ expunge 실패:', expungeErr);
+ return reject(expungeErr);
+ }
+
+ // console.log(`🗑️ 메일 삭제 완료: seqno=${seqno}`);
+ resolve({
+ success: true,
+ message: "메일이 삭제되었습니다.",
+ });
+ });
+ });
+ });
+ });
+
+ imap.once("error", (imapErr: any) => {
+ clearTimeout(timeout);
+ console.error('❌ IMAP 에러:', imapErr);
+ reject(imapErr);
+ });
+
+ imap.once("end", () => {
+ clearTimeout(timeout);
+ });
+
+ // console.log(`🔌 IMAP 연결 시도 중... (host=${config.host}, port=${config.port})`);
+ imap.connect();
+ });
+ }
}
+
+export const mailReceiveBasicService = new MailReceiveBasicService();
diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts
index 188e68c8..b4dce503 100644
--- a/backend-node/src/services/mailSendSimpleService.ts
+++ b/backend-node/src/services/mailSendSimpleService.ts
@@ -34,6 +34,29 @@ export interface SendMailResult {
error?: string;
}
+export interface BulkSendRequest {
+ accountId: string;
+ templateId?: string; // 템플릿 ID (선택)
+ subject: string;
+ customHtml?: string; // 직접 작성한 HTML (선택)
+ recipients: Array<{
+ email: string;
+ variables?: Record; // 템플릿 사용 시에만 필요
+ }>;
+}
+
+export interface BulkSendResult {
+ total: number;
+ success: number;
+ failed: number;
+ results: Array<{
+ email: string;
+ success: boolean;
+ messageId?: string;
+ error?: string;
+ }>;
+}
+
class MailSendSimpleService {
/**
* 단일 메일 발송 또는 소규모 발송
@@ -63,7 +86,7 @@ class MailSendSimpleService {
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
- console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
+ // console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
template.components = request.modifiedTemplateComponents;
}
@@ -84,15 +107,15 @@ class MailSendSimpleService {
// 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
- // console.log('🔐 비밀번호 복호화 완료');
- // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
- // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
+ // // console.log('🔐 비밀번호 복호화 완료');
+ // // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
+ // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 5. SMTP 연결 생성
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
- // console.log('📧 SMTP 연결 설정:', {
+ // // console.log('📧 SMTP 연결 설정:', {
// host: account.smtpHost,
// port: account.smtpPort,
// secure: isSecure,
@@ -112,7 +135,7 @@ class MailSendSimpleService {
greetingTimeout: 30000,
});
- console.log('📧 메일 발송 시도 중...');
+ // console.log('📧 메일 발송 시도 중...');
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
const mailOptions: any = {
@@ -125,13 +148,13 @@ class MailSendSimpleService {
// 참조(CC) 추가
if (request.cc && request.cc.length > 0) {
mailOptions.cc = request.cc.join(', ');
- // console.log('📧 참조(CC):', request.cc);
+ // // console.log('📧 참조(CC):', request.cc);
}
// 숨은참조(BCC) 추가
if (request.bcc && request.bcc.length > 0) {
mailOptions.bcc = request.bcc.join(', ');
- // console.log('🔒 숨은참조(BCC):', request.bcc);
+ // // console.log('🔒 숨은참조(BCC):', request.bcc);
}
// 첨부파일 추가 (한글 파일명 인코딩 처리)
@@ -163,17 +186,17 @@ class MailSendSimpleService {
}
};
});
- console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
- console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
+ // console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
+ // console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
}
const info = await transporter.sendMail(mailOptions);
- console.log('✅ 메일 발송 성공:', {
- messageId: info.messageId,
- accepted: info.accepted,
- rejected: info.rejected,
- });
+ // console.log('✅ 메일 발송 성공:', {
+ // messageId: info.messageId,
+ // accepted: info.accepted,
+ // rejected: info.rejected,
+ // });
// 발송 이력 저장 (성공)
try {
@@ -402,6 +425,73 @@ class MailSendSimpleService {
}
}
+ /**
+ * 대량 메일 발송 (배치 처리)
+ */
+ async sendBulkMail(request: BulkSendRequest): Promise {
+ const results: Array<{
+ email: string;
+ success: boolean;
+ messageId?: string;
+ error?: string;
+ }> = [];
+
+ let successCount = 0;
+ let failedCount = 0;
+
+ // console.log(`📧 대량 발송 시작: ${request.recipients.length}명`);
+
+ // 순차 발송 (너무 빠르면 스팸으로 분류될 수 있음)
+ for (const recipient of request.recipients) {
+ try {
+ const result = await this.sendMail({
+ accountId: request.accountId,
+ templateId: request.templateId, // 템플릿이 있으면 사용
+ customHtml: request.customHtml, // 직접 작성한 HTML이 있으면 사용
+ to: [recipient.email],
+ subject: request.subject,
+ variables: recipient.variables || {}, // 템플릿 사용 시에만 필요
+ });
+
+ if (result.success) {
+ successCount++;
+ results.push({
+ email: recipient.email,
+ success: true,
+ messageId: result.messageId,
+ });
+ } else {
+ failedCount++;
+ results.push({
+ email: recipient.email,
+ success: false,
+ error: result.error || '발송 실패',
+ });
+ }
+ } catch (error: unknown) {
+ const err = error as Error;
+ failedCount++;
+ results.push({
+ email: recipient.email,
+ success: false,
+ error: err.message,
+ });
+ }
+
+ // 발송 간격 (500ms) - 스팸 방지
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+
+ // console.log(`✅ 대량 발송 완료: 성공 ${successCount}, 실패 ${failedCount}`);
+
+ return {
+ total: request.recipients.length,
+ success: successCount,
+ failed: failedCount,
+ results,
+ };
+ }
+
/**
* SMTP 연결 테스트
*/
@@ -414,13 +504,13 @@ class MailSendSimpleService {
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
- // console.log('🔐 테스트용 비밀번호 복호화 완료');
- // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
+ // // console.log('🔐 테스트용 비밀번호 복호화 완료');
+ // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
- // console.log('🧪 SMTP 연결 테스트 시작:', {
+ // // console.log('🧪 SMTP 연결 테스트 시작:', {
// host: account.smtpHost,
// port: account.smtpPort,
// secure: isSecure,
@@ -443,7 +533,7 @@ class MailSendSimpleService {
// 연결 테스트
await transporter.verify();
- console.log('✅ SMTP 연결 테스트 성공');
+ // console.log('✅ SMTP 연결 테스트 성공');
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
} catch (error) {
const err = error as Error;
diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts
index c7828888..f0a80265 100644
--- a/backend-node/src/services/mailSentHistoryService.ts
+++ b/backend-node/src/services/mailSentHistoryService.ts
@@ -53,7 +53,7 @@ class MailSentHistoryService {
mode: 0o644,
});
- console.log("발송 이력 저장:", history.id);
+ // console.log("발송 이력 저장:", history.id);
} catch (error) {
console.error("발송 이력 저장 실패:", error);
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
@@ -86,7 +86,7 @@ class MailSentHistoryService {
try {
// 디렉토리가 없으면 빈 배열 반환
if (!fs.existsSync(SENT_MAIL_DIR)) {
- console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
+ // console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
return {
items: [],
total: 0,
@@ -124,6 +124,13 @@ class MailSentHistoryService {
// 필터링
let filtered = allHistory;
+ // 삭제된 메일 필터
+ if (query.onlyDeleted) {
+ filtered = filtered.filter((h) => h.deletedAt);
+ } else if (!query.includeDeleted) {
+ filtered = filtered.filter((h) => !h.deletedAt);
+ }
+
// 상태 필터
if (status !== "all") {
filtered = filtered.filter((h) => h.status === status);
@@ -209,9 +216,151 @@ class MailSentHistoryService {
}
/**
- * 발송 이력 삭제
+ * 임시 저장 (Draft)
+ */
+ async saveDraft(
+ data: Partial & { accountId: string }
+ ): Promise {
+ // console.log("📥 백엔드에서 받은 임시 저장 데이터:", data);
+
+ const now = new Date().toISOString();
+ const draft: SentMailHistory = {
+ id: data.id || uuidv4(),
+ accountId: data.accountId,
+ accountName: data.accountName || "",
+ accountEmail: data.accountEmail || "",
+ to: data.to || [],
+ cc: data.cc,
+ bcc: data.bcc,
+ subject: data.subject || "",
+ htmlContent: data.htmlContent || "",
+ templateId: data.templateId,
+ templateName: data.templateName,
+ attachments: data.attachments,
+ sentAt: data.sentAt || now,
+ status: "draft",
+ isDraft: true,
+ updatedAt: now,
+ };
+
+ // console.log("💾 저장할 draft 객체:", draft);
+
+ try {
+ if (!fs.existsSync(SENT_MAIL_DIR)) {
+ fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
+ }
+
+ const filePath = path.join(SENT_MAIL_DIR, `${draft.id}.json`);
+ fs.writeFileSync(filePath, JSON.stringify(draft, null, 2), {
+ encoding: "utf-8",
+ mode: 0o644,
+ });
+
+ // console.log("💾 임시 저장:", draft.id);
+ } catch (error) {
+ console.error("임시 저장 실패:", error);
+ throw error;
+ }
+
+ return draft;
+ }
+
+ /**
+ * 임시 저장 업데이트
+ */
+ async updateDraft(
+ id: string,
+ data: Partial
+ ): Promise {
+ const existing = await this.getSentMailById(id);
+ if (!existing) {
+ return null;
+ }
+
+ const updated: SentMailHistory = {
+ ...existing,
+ ...data,
+ id: existing.id,
+ updatedAt: new Date().toISOString(),
+ };
+
+ try {
+ const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
+ fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
+ encoding: "utf-8",
+ mode: 0o644,
+ });
+
+ // console.log("✏️ 임시 저장 업데이트:", id);
+ return updated;
+ } catch (error) {
+ console.error("임시 저장 업데이트 실패:", error);
+ return null;
+ }
+ }
+
+ /**
+ * 발송 이력 삭제 (Soft Delete)
*/
async deleteSentMail(id: string): Promise {
+ const existing = await this.getSentMailById(id);
+ if (!existing) {
+ return false;
+ }
+
+ const updated: SentMailHistory = {
+ ...existing,
+ deletedAt: new Date().toISOString(),
+ };
+
+ try {
+ const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
+ fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
+ encoding: "utf-8",
+ mode: 0o644,
+ });
+
+ // console.log("🗑️ 메일 삭제 (Soft Delete):", id);
+ return true;
+ } catch (error) {
+ console.error("메일 삭제 실패:", error);
+ return false;
+ }
+ }
+
+ /**
+ * 메일 복구
+ */
+ async restoreMail(id: string): Promise {
+ const existing = await this.getSentMailById(id);
+ if (!existing || !existing.deletedAt) {
+ return false;
+ }
+
+ const updated: SentMailHistory = {
+ ...existing,
+ deletedAt: undefined,
+ };
+
+ try {
+ const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
+ fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
+ encoding: "utf-8",
+ mode: 0o644,
+ });
+
+ // console.log("♻️ 메일 복구:", id);
+ return true;
+ } catch (error) {
+ console.error("메일 복구 실패:", error);
+ return false;
+ }
+ }
+
+ /**
+ * 메일 영구 삭제 (Hard Delete)
+ */
+ async permanentlyDeleteMail(id: string): Promise {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
@@ -220,14 +369,57 @@ class MailSentHistoryService {
try {
fs.unlinkSync(filePath);
- console.log("🗑️ 발송 이력 삭제:", id);
+ // console.log("🗑️ 메일 영구 삭제:", id);
return true;
} catch (error) {
- console.error("발송 이력 삭제 실패:", error);
+ console.error("메일 영구 삭제 실패:", error);
return false;
}
}
+ /**
+ * 30일 이상 지난 삭제된 메일 자동 영구 삭제
+ */
+ async cleanupOldDeletedMails(): Promise {
+ const thirtyDaysAgo = new Date();
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+ let deletedCount = 0;
+
+ try {
+ if (!fs.existsSync(SENT_MAIL_DIR)) {
+ return 0;
+ }
+
+ const files = fs
+ .readdirSync(SENT_MAIL_DIR)
+ .filter((f) => f.endsWith(".json"));
+
+ for (const file of files) {
+ try {
+ const filePath = path.join(SENT_MAIL_DIR, file);
+ const content = fs.readFileSync(filePath, "utf-8");
+ const mail: SentMailHistory = JSON.parse(content);
+
+ if (mail.deletedAt) {
+ const deletedDate = new Date(mail.deletedAt);
+ if (deletedDate < thirtyDaysAgo) {
+ fs.unlinkSync(filePath);
+ deletedCount++;
+ // console.log("🗑️ 30일 지난 메일 자동 삭제:", mail.id);
+ }
+ }
+ } catch (error) {
+ console.error(`파일 처리 실패: ${file}`, error);
+ }
+ }
+ } catch (error) {
+ console.error("자동 삭제 실패:", error);
+ }
+
+ return deletedCount;
+ }
+
/**
* 통계 조회
*/
diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts
index e1a878b9..adb72fff 100644
--- a/backend-node/src/services/mailTemplateFileService.ts
+++ b/backend-node/src/services/mailTemplateFileService.ts
@@ -50,11 +50,25 @@ class MailTemplateFileService {
process.env.NODE_ENV === "production"
? "/app/uploads/mail-templates"
: path.join(process.cwd(), "uploads", "mail-templates");
- this.ensureDirectoryExists();
+ // 동기적으로 디렉토리 생성
+ this.ensureDirectoryExistsSync();
}
/**
- * 템플릿 디렉토리 생성
+ * 템플릿 디렉토리 생성 (동기)
+ */
+ private ensureDirectoryExistsSync() {
+ try {
+ const fsSync = require('fs');
+ fsSync.accessSync(this.templatesDir);
+ } catch {
+ const fsSync = require('fs');
+ fsSync.mkdirSync(this.templatesDir, { recursive: true, mode: 0o755 });
+ }
+ }
+
+ /**
+ * 템플릿 디렉토리 생성 (비동기)
*/
private async ensureDirectoryExists() {
try {
@@ -75,8 +89,6 @@ class MailTemplateFileService {
* 모든 템플릿 목록 조회
*/
async getAllTemplates(): Promise {
- await this.ensureDirectoryExists();
-
try {
const files = await fs.readdir(this.templatesDir);
const jsonFiles = files.filter((f) => f.endsWith(".json"));
@@ -97,6 +109,7 @@ class MailTemplateFileService {
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
} catch (error) {
+ // 디렉토리가 없거나 읽기 실패 시 빈 배열 반환
return [];
}
}
@@ -160,7 +173,7 @@ class MailTemplateFileService {
updatedAt: new Date().toISOString(),
};
- // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
+ // // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
await fs.writeFile(
this.getTemplatePath(id),
@@ -168,7 +181,7 @@ class MailTemplateFileService {
"utf-8"
);
- // console.log(`✅ 템플릿 저장 성공: ${id}`);
+ // // console.log(`✅ 템플릿 저장 성공: ${id}`);
return updated;
} catch (error) {
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
diff --git a/backend-node/src/services/riskAlertCacheService.ts b/backend-node/src/services/riskAlertCacheService.ts
index cc4de181..ce8b6089 100644
--- a/backend-node/src/services/riskAlertCacheService.ts
+++ b/backend-node/src/services/riskAlertCacheService.ts
@@ -34,16 +34,35 @@ export class RiskAlertCacheService {
*/
public startAutoRefresh(): void {
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
+ console.log(' - 기상특보: 즉시 호출');
+ console.log(' - 교통사고/도로공사: 10분 후 첫 호출');
- // 즉시 첫 갱신
- this.refreshCache();
+ // 기상특보만 즉시 호출 (ITS API는 10분 후부터)
+ this.refreshWeatherOnly();
- // 10분마다 갱신 (600,000ms)
+ // 10분마다 전체 갱신 (600,000ms)
this.updateInterval = setInterval(() => {
this.refreshCache();
}, 10 * 60 * 1000);
}
+ /**
+ * 기상특보만 갱신 (재시작 시 사용)
+ */
+ private async refreshWeatherOnly(): Promise {
+ try {
+ console.log('🌤️ 기상특보만 즉시 갱신 중...');
+ const weatherAlerts = await this.riskAlertService.getWeatherAlerts();
+
+ this.cachedAlerts = weatherAlerts;
+ this.lastUpdated = new Date();
+
+ console.log(`✅ 기상특보 갱신 완료! (${weatherAlerts.length}건)`);
+ } catch (error: any) {
+ console.error('❌ 기상특보 갱신 실패:', error.message);
+ }
+ }
+
/**
* 자동 갱신 중지
*/
diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts
index a984fa85..198c850b 100644
--- a/backend-node/src/services/screenManagementService.ts
+++ b/backend-node/src/services/screenManagementService.ts
@@ -71,8 +71,9 @@ export class ScreenManagementService {
// 화면 생성 (Raw Query)
const [screen] = await query(
`INSERT INTO screen_definitions (
- screen_name, screen_code, table_name, company_code, description, created_by
- ) VALUES ($1, $2, $3, $4, $5, $6)
+ screen_name, screen_code, table_name, company_code, description, created_by,
+ db_source_type, db_connection_id
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
screenData.screenName,
@@ -81,6 +82,8 @@ export class ScreenManagementService {
screenData.companyCode,
screenData.description || null,
screenData.createdBy,
+ screenData.dbSourceType || "internal",
+ screenData.dbConnectionId || null,
]
);
@@ -1779,6 +1782,8 @@ export class ScreenManagementService {
createdBy: data.created_by,
updatedDate: data.updated_date,
updatedBy: data.updated_by,
+ dbSourceType: data.db_source_type || "internal",
+ dbConnectionId: data.db_connection_id || undefined,
};
}
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index 83f3a696..10de1e73 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -3118,4 +3118,410 @@ export class TableManagementService {
// 기본값
return "text";
}
+
+ // ========================================
+ // 🎯 테이블 로그 시스템
+ // ========================================
+
+ /**
+ * 로그 테이블 생성
+ */
+ async createLogTable(
+ tableName: string,
+ pkColumn: { columnName: string; dataType: string },
+ userId?: string
+ ): Promise {
+ try {
+ const logTableName = `${tableName}_log`;
+ const triggerFuncName = `${tableName}_log_trigger_func`;
+ const triggerName = `${tableName}_audit_trigger`;
+
+ logger.info(`로그 테이블 생성 시작: ${logTableName}`);
+
+ // 로그 테이블 DDL 생성
+ const logTableDDL = this.generateLogTableDDL(
+ logTableName,
+ tableName,
+ pkColumn.columnName,
+ pkColumn.dataType
+ );
+
+ // 트리거 함수 DDL 생성
+ const triggerFuncDDL = this.generateTriggerFunctionDDL(
+ triggerFuncName,
+ logTableName,
+ tableName,
+ pkColumn.columnName
+ );
+
+ // 트리거 DDL 생성
+ const triggerDDL = this.generateTriggerDDL(
+ triggerName,
+ tableName,
+ triggerFuncName
+ );
+
+ // 트랜잭션으로 실행
+ await transaction(async (client) => {
+ // 1. 로그 테이블 생성
+ await client.query(logTableDDL);
+ logger.info(`로그 테이블 생성 완료: ${logTableName}`);
+
+ // 2. 트리거 함수 생성
+ await client.query(triggerFuncDDL);
+ logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
+
+ // 3. 트리거 생성
+ await client.query(triggerDDL);
+ logger.info(`트리거 생성 완료: ${triggerName}`);
+
+ // 4. 로그 설정 저장
+ await client.query(
+ `INSERT INTO table_log_config (
+ original_table_name, log_table_name, trigger_name,
+ trigger_function_name, created_by
+ ) VALUES ($1, $2, $3, $4, $5)`,
+ [tableName, logTableName, triggerName, triggerFuncName, userId]
+ );
+ logger.info(`로그 설정 저장 완료: ${tableName}`);
+ });
+
+ logger.info(`로그 테이블 생성 완료: ${logTableName}`);
+ } catch (error) {
+ logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
+ throw new Error(
+ `로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
+ );
+ }
+ }
+
+ /**
+ * 로그 테이블 DDL 생성
+ */
+ private generateLogTableDDL(
+ logTableName: string,
+ originalTableName: string,
+ pkColumnName: string,
+ pkDataType: string
+ ): string {
+ return `
+ CREATE TABLE ${logTableName} (
+ log_id SERIAL PRIMARY KEY,
+ operation_type VARCHAR(10) NOT NULL,
+ original_id VARCHAR(100),
+ changed_column VARCHAR(100),
+ old_value TEXT,
+ new_value TEXT,
+ changed_by VARCHAR(50),
+ changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ ip_address VARCHAR(50),
+ user_agent TEXT,
+ full_row_before JSONB,
+ full_row_after JSONB
+ );
+
+ CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
+ CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
+ CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
+
+ COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
+ COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
+ COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
+ COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
+ COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
+ COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
+ COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
+ COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
+ COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
+ COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
+ COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
+ `;
+ }
+
+ /**
+ * 트리거 함수 DDL 생성
+ */
+ private generateTriggerFunctionDDL(
+ funcName: string,
+ logTableName: string,
+ originalTableName: string,
+ pkColumnName: string
+ ): string {
+ return `
+ CREATE OR REPLACE FUNCTION ${funcName}()
+ RETURNS TRIGGER AS $$
+ DECLARE
+ v_column_name TEXT;
+ v_old_value TEXT;
+ v_new_value TEXT;
+ v_user_id VARCHAR(50);
+ v_ip_address VARCHAR(50);
+ BEGIN
+ v_user_id := current_setting('app.user_id', TRUE);
+ v_ip_address := current_setting('app.ip_address', TRUE);
+
+ IF (TG_OP = 'INSERT') THEN
+ EXECUTE format(
+ 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
+ VALUES ($1, ($2).%I, $3, $4, $5)',
+ '${pkColumnName}'
+ )
+ USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
+ RETURN NEW;
+
+ ELSIF (TG_OP = 'UPDATE') THEN
+ FOR v_column_name IN
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = '${originalTableName}'
+ AND table_schema = 'public'
+ LOOP
+ EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
+ INTO v_old_value, v_new_value
+ USING OLD, NEW;
+
+ IF v_old_value IS DISTINCT FROM v_new_value THEN
+ EXECUTE format(
+ 'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
+ VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
+ '${pkColumnName}'
+ )
+ USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
+ END IF;
+ END LOOP;
+ RETURN NEW;
+
+ ELSIF (TG_OP = 'DELETE') THEN
+ EXECUTE format(
+ 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
+ VALUES ($1, ($2).%I, $3, $4, $5)',
+ '${pkColumnName}'
+ )
+ USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
+ RETURN OLD;
+ END IF;
+
+ RETURN NULL;
+ END;
+ $$ LANGUAGE plpgsql;
+ `;
+ }
+
+ /**
+ * 트리거 DDL 생성
+ */
+ private generateTriggerDDL(
+ triggerName: string,
+ tableName: string,
+ funcName: string
+ ): string {
+ return `
+ CREATE TRIGGER ${triggerName}
+ AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
+ FOR EACH ROW EXECUTE FUNCTION ${funcName}();
+ `;
+ }
+
+ /**
+ * 로그 설정 조회
+ */
+ async getLogConfig(tableName: string): Promise<{
+ originalTableName: string;
+ logTableName: string;
+ triggerName: string;
+ triggerFunctionName: string;
+ isActive: string;
+ createdAt: Date;
+ createdBy: string;
+ } | null> {
+ try {
+ logger.info(`로그 설정 조회: ${tableName}`);
+
+ const result = await queryOne<{
+ original_table_name: string;
+ log_table_name: string;
+ trigger_name: string;
+ trigger_function_name: string;
+ is_active: string;
+ created_at: Date;
+ created_by: string;
+ }>(
+ `SELECT
+ original_table_name, log_table_name, trigger_name,
+ trigger_function_name, is_active, created_at, created_by
+ FROM table_log_config
+ WHERE original_table_name = $1`,
+ [tableName]
+ );
+
+ if (!result) {
+ return null;
+ }
+
+ return {
+ originalTableName: result.original_table_name,
+ logTableName: result.log_table_name,
+ triggerName: result.trigger_name,
+ triggerFunctionName: result.trigger_function_name,
+ isActive: result.is_active,
+ createdAt: result.created_at,
+ createdBy: result.created_by,
+ };
+ } catch (error) {
+ logger.error(`로그 설정 조회 실패: ${tableName}`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * 로그 데이터 조회
+ */
+ async getLogData(
+ tableName: string,
+ options: {
+ page: number;
+ size: number;
+ operationType?: string;
+ startDate?: string;
+ endDate?: string;
+ changedBy?: string;
+ originalId?: string;
+ }
+ ): Promise<{
+ data: any[];
+ total: number;
+ page: number;
+ size: number;
+ totalPages: number;
+ }> {
+ try {
+ const logTableName = `${tableName}_log`;
+ const offset = (options.page - 1) * options.size;
+
+ logger.info(`로그 데이터 조회: ${logTableName}`, options);
+
+ // WHERE 조건 구성
+ const whereConditions: string[] = [];
+ const values: any[] = [];
+ let paramIndex = 1;
+
+ if (options.operationType) {
+ whereConditions.push(`operation_type = $${paramIndex}`);
+ values.push(options.operationType);
+ paramIndex++;
+ }
+
+ if (options.startDate) {
+ whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
+ values.push(options.startDate);
+ paramIndex++;
+ }
+
+ if (options.endDate) {
+ whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
+ values.push(options.endDate);
+ paramIndex++;
+ }
+
+ if (options.changedBy) {
+ whereConditions.push(`changed_by = $${paramIndex}`);
+ values.push(options.changedBy);
+ paramIndex++;
+ }
+
+ if (options.originalId) {
+ whereConditions.push(`original_id::text = $${paramIndex}`);
+ values.push(options.originalId);
+ paramIndex++;
+ }
+
+ const whereClause =
+ whereConditions.length > 0
+ ? `WHERE ${whereConditions.join(" AND ")}`
+ : "";
+
+ // 전체 개수 조회
+ const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
+ const countResult = await query(countQuery, values);
+ const total = parseInt(countResult[0].count);
+
+ // 데이터 조회
+ const dataQuery = `
+ SELECT * FROM ${logTableName}
+ ${whereClause}
+ ORDER BY changed_at DESC
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
+ `;
+
+ const data = await query(dataQuery, [
+ ...values,
+ options.size,
+ offset,
+ ]);
+
+ const totalPages = Math.ceil(total / options.size);
+
+ logger.info(
+ `로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
+ );
+
+ return {
+ data,
+ total,
+ page: options.page,
+ size: options.size,
+ totalPages,
+ };
+ } catch (error) {
+ logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
+ throw error;
+ }
+ }
+
+ /**
+ * 로그 테이블 활성화/비활성화
+ */
+ async toggleLogTable(tableName: string, isActive: boolean): Promise {
+ try {
+ const logConfig = await this.getLogConfig(tableName);
+ if (!logConfig) {
+ throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
+ }
+
+ logger.info(
+ `로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
+ );
+
+ await transaction(async (client) => {
+ // 트리거 활성화/비활성화
+ if (isActive) {
+ await client.query(
+ `ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
+ );
+ } else {
+ await client.query(
+ `ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
+ );
+ }
+
+ // 설정 업데이트
+ await client.query(
+ `UPDATE table_log_config
+ SET is_active = $1, updated_at = NOW()
+ WHERE original_table_name = $2`,
+ [isActive ? "Y" : "N", tableName]
+ );
+ });
+
+ logger.info(
+ `로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
+ );
+ } catch (error) {
+ logger.error(
+ `로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
+ error
+ );
+ throw error;
+ }
+ }
}
diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts
index 33becbb9..c7f12dee 100644
--- a/backend-node/src/services/todoService.ts
+++ b/backend-node/src/services/todoService.ts
@@ -155,10 +155,15 @@ export class TodoService {
updates: Partial
): Promise {
try {
- if (DATA_SOURCE === "database") {
+ // 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기
+ try {
return await this.updateTodoDB(id, updates);
- } else {
- return this.updateTodoFile(id, updates);
+ } catch (dbError: any) {
+ // 데이터베이스에서 찾지 못했으면 파일에서 찾기
+ if (dbError.message && dbError.message.includes("찾을 수 없습니다")) {
+ return this.updateTodoFile(id, updates);
+ }
+ throw dbError;
}
} catch (error) {
logger.error("❌ To-Do 수정 오류:", error);
@@ -171,10 +176,16 @@ export class TodoService {
*/
public async deleteTodo(id: string): Promise {
try {
- if (DATA_SOURCE === "database") {
+ // 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기
+ try {
await this.deleteTodoDB(id);
- } else {
- this.deleteTodoFile(id);
+ } catch (dbError: any) {
+ // 데이터베이스에서 찾지 못했으면 파일에서 찾기
+ if (dbError.message && dbError.message.includes("찾을 수 없습니다")) {
+ this.deleteTodoFile(id);
+ } else {
+ throw dbError;
+ }
}
logger.info(`✅ To-Do 삭제: ${id}`);
} catch (error) {
diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts
index b03acbff..7d6267a7 100644
--- a/backend-node/src/types/dashboard.ts
+++ b/backend-node/src/types/dashboard.ts
@@ -45,6 +45,17 @@ export interface DashboardElement {
layoutId: number;
layoutName?: string;
};
+ customMetricConfig?: {
+ metrics: Array<{
+ id: string;
+ field: string;
+ label: string;
+ aggregation: "count" | "sum" | "avg" | "min" | "max";
+ unit: string;
+ color: "indigo" | "green" | "blue" | "purple" | "orange" | "gray";
+ decimals: number;
+ }>;
+ };
}
export interface Dashboard {
diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts
new file mode 100644
index 00000000..061ab6b8
--- /dev/null
+++ b/backend-node/src/types/externalRestApiTypes.ts
@@ -0,0 +1,78 @@
+// 외부 REST API 연결 관리 타입 정의
+
+export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
+
+export interface ExternalRestApiConnection {
+ id?: number;
+ connection_name: string;
+ description?: string;
+ base_url: string;
+ default_headers: Record;
+ auth_type: AuthType;
+ auth_config?: {
+ // API Key
+ keyLocation?: "header" | "query";
+ keyName?: string;
+ keyValue?: string;
+
+ // Bearer Token
+ token?: string;
+
+ // Basic Auth
+ username?: string;
+ password?: string;
+
+ // OAuth2
+ clientId?: string;
+ clientSecret?: string;
+ tokenUrl?: string;
+ accessToken?: string;
+ };
+ timeout?: number;
+ retry_count?: number;
+ retry_delay?: number;
+ company_code: string;
+ is_active: string;
+ created_date?: Date;
+ created_by?: string;
+ updated_date?: Date;
+ updated_by?: string;
+ last_test_date?: Date;
+ last_test_result?: string;
+ last_test_message?: string;
+}
+
+export interface ExternalRestApiConnectionFilter {
+ auth_type?: string;
+ is_active?: string;
+ company_code?: string;
+ search?: string;
+}
+
+export interface RestApiTestRequest {
+ id?: number;
+ base_url: string;
+ endpoint?: string;
+ method?: "GET" | "POST" | "PUT" | "DELETE";
+ headers?: Record;
+ auth_type?: AuthType;
+ auth_config?: any;
+ timeout?: number;
+}
+
+export interface RestApiTestResult {
+ success: boolean;
+ message: string;
+ response_time?: number;
+ status_code?: number;
+ response_data?: any;
+ error_details?: string;
+}
+
+export const AUTH_TYPE_OPTIONS = [
+ { value: "none", label: "인증 없음" },
+ { value: "api-key", label: "API Key" },
+ { value: "bearer", label: "Bearer Token" },
+ { value: "basic", label: "Basic Auth" },
+ { value: "oauth2", label: "OAuth 2.0" },
+];
diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts
index 3483b617..4368ae1a 100644
--- a/backend-node/src/types/flow.ts
+++ b/backend-node/src/types/flow.ts
@@ -8,6 +8,8 @@ export interface FlowDefinition {
name: string;
description?: string;
tableName: string;
+ dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
+ dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
isActive: boolean;
createdBy?: string;
createdAt: Date;
@@ -19,6 +21,8 @@ export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
+ dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
+ dbConnectionId?: number; // 외부 DB 연결 ID
}
// 플로우 정의 수정 요청
@@ -178,6 +182,9 @@ export interface FlowAuditLog {
targetDataId?: string;
statusFrom?: string;
statusTo?: string;
+ // 외부 DB 연결 정보
+ dbConnectionId?: number;
+ dbConnectionName?: string;
// 조인 필드
fromStepName?: string;
toStepName?: string;
diff --git a/backend-node/src/types/mailSentHistory.ts b/backend-node/src/types/mailSentHistory.ts
index 1366acf4..856cbd4f 100644
--- a/backend-node/src/types/mailSentHistory.ts
+++ b/backend-node/src/types/mailSentHistory.ts
@@ -24,13 +24,18 @@ export interface SentMailHistory {
// 발송 정보
sentAt: string; // 발송 시간 (ISO 8601)
- status: 'success' | 'failed'; // 발송 상태
+ status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가)
messageId?: string; // SMTP 메시지 ID (성공 시)
errorMessage?: string; // 오류 메시지 (실패 시)
// 발송 결과
accepted?: string[]; // 수락된 이메일 주소
rejected?: string[]; // 거부된 이메일 주소
+
+ // 임시 저장 및 삭제
+ isDraft?: boolean; // 임시 저장 여부
+ deletedAt?: string; // 삭제 시간 (ISO 8601)
+ updatedAt?: string; // 수정 시간 (ISO 8601)
}
export interface AttachmentInfo {
@@ -45,12 +50,14 @@ export interface SentMailListQuery {
page?: number; // 페이지 번호 (1부터 시작)
limit?: number; // 페이지당 항목 수
searchTerm?: string; // 검색어 (제목, 받는사람)
- status?: 'success' | 'failed' | 'all'; // 필터: 상태
+ status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가)
accountId?: string; // 필터: 발송 계정
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
- sortBy?: 'sentAt' | 'subject'; // 정렬 기준
+ sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가)
sortOrder?: 'asc' | 'desc'; // 정렬 순서
+ includeDeleted?: boolean; // 삭제된 메일 포함 여부
+ onlyDeleted?: boolean; // 삭제된 메일만 조회
}
export interface SentMailListResponse {
diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts
index 8075c78c..304c589c 100644
--- a/backend-node/src/types/screen.ts
+++ b/backend-node/src/types/screen.ts
@@ -151,6 +151,8 @@ export interface ScreenDefinition {
createdBy?: string;
updatedDate: Date;
updatedBy?: string;
+ dbSourceType?: "internal" | "external";
+ dbConnectionId?: number;
}
// 화면 생성 요청
@@ -161,6 +163,8 @@ export interface CreateScreenRequest {
companyCode: string;
description?: string;
createdBy?: string;
+ dbSourceType?: "internal" | "external";
+ dbConnectionId?: number;
}
// 화면 수정 요청
diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile
index f5d54a9e..cba88e5c 100644
--- a/docker/prod/backend.Dockerfile
+++ b/docker/prod/backend.Dockerfile
@@ -1,13 +1,9 @@
-# syntax=docker/dockerfile:1
-
-# Base image (Debian-based for glibc + OpenSSL compatibility)
-FROM node:20-bookworm-slim AS base
+# Base image (WACE Docker Hub)
+FROM dockerhub.wace.me/node:20.19-alpine.linux AS base
WORKDIR /app
ENV NODE_ENV=production
# Install OpenSSL, curl (for healthcheck), and required certs
-RUN apt-get update \
- && apt-get install -y --no-install-recommends openssl ca-certificates curl \
- && rm -rf /var/lib/apt/lists/*
+RUN apk add --no-cache openssl ca-certificates curl
# Dependencies stage (install production dependencies)
FROM base AS deps
@@ -15,7 +11,7 @@ COPY package*.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
# Build stage (compile TypeScript)
-FROM node:20-bookworm-slim AS build
+FROM dockerhub.wace.me/node:20.19-alpine.linux AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
@@ -27,8 +23,8 @@ RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
-# Create non-root user
-RUN groupadd -r appgroup && useradd -r -g appgroup appuser
+# Create non-root user (Alpine 방식)
+RUN addgroup -S appgroup && adduser -S -G appgroup appuser
# Copy production node_modules
COPY --from=deps /app/node_modules ./node_modules
diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml
index 507a555b..e8a4e04c 100644
--- a/docker/prod/docker-compose.backend.prod.yml
+++ b/docker/prod/docker-compose.backend.prod.yml
@@ -5,25 +5,18 @@ services:
context: ../../backend-node
dockerfile: ../docker/prod/backend.Dockerfile
container_name: plm-backend
- restart: always
environment:
- NODE_ENV: production
- PORT: "3001"
- DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN: 24h
- CORS_ORIGIN: https://v1.vexplor.com
- CORS_CREDENTIALS: "true"
- LOG_LEVEL: info
- volumes:
- - /home/vexplor/backend_data:/app/uploads
- labels:
- - traefik.enable=true
- - traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
- - traefik.http.routers.backend.entrypoints=websecure,web
- - traefik.http.routers.backend.tls=true
- - traefik.http.routers.backend.tls.certresolver=le
- - traefik.http.services.backend.loadbalancer.server.port=3001
+ - NODE_ENV=production
+ - PORT=8080
+ - HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
+ - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
+ - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
+ - JWT_EXPIRES_IN=24h
+ - CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771
+ - CORS_CREDENTIALS=true
+ - LOG_LEVEL=info
+ - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
+ restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
diff --git a/docker/prod/frontend.Dockerfile b/docker/prod/frontend.Dockerfile
index b2e1600d..e4741ad5 100644
--- a/docker/prod/frontend.Dockerfile
+++ b/docker/prod/frontend.Dockerfile
@@ -1,5 +1,5 @@
# Multi-stage build for Next.js
-FROM node:18-alpine AS base
+FROM dockerhub.wace.me/node:20.19-alpine.linux AS base
# curl 설치 (헬스체크용)
RUN apk add --no-cache curl
diff --git a/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md b/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md
new file mode 100644
index 00000000..2ff7f4e1
--- /dev/null
+++ b/docs/ADMIN_STYLE_GUIDE_EXAMPLE.md
@@ -0,0 +1,435 @@
+# 관리자 페이지 스타일 가이드 적용 예시
+
+## 개요
+
+사용자 관리 페이지를 예시로 shadcn/ui 스타일 가이드에 맞춰 재작성했습니다.
+이 예시를 기준으로 다른 관리자 페이지들도 일관된 스타일로 통일할 수 있습니다.
+
+## 적용된 주요 원칙
+
+### 1. Color System (색상 시스템)
+
+**CSS Variables 사용 (하드코딩된 색상 금지)**
+```tsx
+// ❌ 잘못된 예시
+
+
+// ✅ 올바른 예시
+
+
+
+
+
+```
+
+**적용 사례:**
+- 페이지 배경: `bg-background`
+- 카드 배경: `bg-card`
+- 보조 텍스트: `text-muted-foreground`
+- 주요 액션: `text-primary`, `border-primary`
+- 에러 메시지: `text-destructive`, `bg-destructive/10`
+
+### 2. Typography (타이포그래피)
+
+**일관된 폰트 크기와 가중치**
+```tsx
+// 페이지 제목
+
사용자 관리
+
+// 섹션 제목
+
고급 검색 옵션
+
+// 본문 텍스트
+
설명 텍스트
+
+// 라벨
+
필드 라벨
+
+// 보조 텍스트
+
도움말
+```
+
+### 3. Spacing System (간격)
+
+**일관된 간격 사용 (4px 기준)**
+```tsx
+// 컴포넌트 간 간격
+
// 24px (페이지 레벨)
+
// 16px (섹션 레벨)
+
// 8px (필드 레벨)
+
+// 패딩
+
// 24px (카드)
+
// 16px (내부 섹션)
+
+// 갭
+
// 16px (flex/grid)
+
// 8px (버튼 그룹)
+```
+
+### 4. Border & Radius (테두리 및 둥근 모서리)
+
+**표준 radius 사용**
+```tsx
+// 카드/패널
+
+
+// 입력 필드
+
+
+// 버튼
+
+```
+
+### 5. Button Variants (버튼 스타일)
+
+**표준 variants 사용**
+```tsx
+// Primary 액션
+
+
+ 사용자 등록
+
+
+// Secondary 액션
+
+ 고급 검색
+
+
+// Ghost 버튼 (아이콘 전용)
+
+
+
+```
+
+**크기 표준:**
+- `h-10`: 기본 버튼 (40px)
+- `h-9`: 작은 버튼 (36px)
+- `h-8`: 아이콘 버튼 (32px)
+
+### 6. Input States (입력 필드 상태)
+
+**표준 Input 스타일**
+```tsx
+// 기본
+
+
+// 포커스 (자동 적용)
+// focus:ring-2 focus:ring-ring
+
+// 로딩/액티브
+
+
+// 비활성화
+
+```
+
+### 7. Form Structure (폼 구조)
+
+**표준 필드 구조**
+```tsx
+
+
+ 필드 라벨
+
+
+
+ 도움말 텍스트
+
+
+```
+
+### 8. Table Structure (테이블 구조)
+
+**표준 테이블 스타일**
+```tsx
+
+
+
+
+
+ 컬럼명
+
+
+
+
+
+
+ 데이터
+
+
+
+
+
+```
+
+**높이 표준:**
+- 헤더: `h-12` (48px)
+- 데이터 행: `h-16` (64px)
+
+### 9. Loading States (로딩 상태)
+
+**Skeleton UI**
+```tsx
+
+```
+
+### 10. Empty States (빈 상태)
+
+**표준 Empty State**
+```tsx
+
+
+
+```
+
+### 11. Error States (에러 상태)
+
+**표준 에러 메시지**
+```tsx
+
+```
+
+### 12. Responsive Design (반응형)
+
+**모바일 우선 접근**
+```tsx
+// 레이아웃
+
+
+// 그리드
+
+
+// 텍스트
+
+
+// 간격
+
+```
+
+### 13. Accessibility (접근성)
+
+**필수 적용 사항**
+```tsx
+// Label과 Input 연결
+
+ 사용자 ID
+
+
+
+// 버튼에 aria-label
+
+ ✕
+
+
+// Switch에 aria-label
+
+```
+
+## 페이지 구조 템플릿
+
+### Page Component
+```tsx
+export default function AdminPage() {
+ return (
+
+
+ {/* 페이지 헤더 */}
+
+
+ {/* 메인 컨텐츠 */}
+
+
+
+ );
+}
+```
+
+### Toolbar Component
+```tsx
+export function Toolbar() {
+ return (
+
+ {/* 검색 영역 */}
+
+
+ {/* 검색 입력 */}
+
+
+ {/* 버튼 */}
+
+ 고급 검색
+
+
+
+
+ {/* 액션 버튼 영역 */}
+
+
+ 총 {count.toLocaleString()} 건
+
+
+
+
+ 등록
+
+
+
+ );
+}
+```
+
+## 적용해야 할 다른 관리자 페이지
+
+### 우선순위 1 (핵심 페이지)
+- [ ] 메뉴 관리 (`/admin/menu`)
+- [ ] 공통코드 관리 (`/admin/commonCode`)
+- [ ] 회사 관리 (`/admin/company`)
+- [ ] 테이블 관리 (`/admin/tableMng`)
+
+### 우선순위 2 (자주 사용하는 페이지)
+- [ ] 외부 연결 관리 (`/admin/external-connections`)
+- [ ] 외부 호출 설정 (`/admin/external-call-configs`)
+- [ ] 배치 관리 (`/admin/batch-management`)
+- [ ] 레이아웃 관리 (`/admin/layouts`)
+
+### 우선순위 3 (기타 관리 페이지)
+- [ ] 템플릿 관리 (`/admin/templates`)
+- [ ] 표준 관리 (`/admin/standards`)
+- [ ] 다국어 관리 (`/admin/i18n`)
+- [ ] 수집 관리 (`/admin/collection-management`)
+
+## 체크리스트
+
+각 페이지 작업 시 다음을 확인하세요:
+
+### 레이아웃
+- [ ] `bg-background` 사용 (하드코딩된 색상 없음)
+- [ ] `container mx-auto space-y-6 p-6` 구조
+- [ ] 페이지 헤더에 `border-b pb-4`
+
+### 색상
+- [ ] CSS Variables만 사용 (`bg-card`, `text-muted-foreground` 등)
+- [ ] `bg-gray-*`, `text-gray-*` 등 하드코딩 제거
+
+### 타이포그래피
+- [ ] 페이지 제목: `text-3xl font-bold tracking-tight`
+- [ ] 섹션 제목: `text-sm font-semibold`
+- [ ] 본문: `text-sm`
+- [ ] 보조 텍스트: `text-xs text-muted-foreground`
+
+### 간격
+- [ ] 페이지 레벨: `space-y-6`
+- [ ] 섹션 레벨: `space-y-4`
+- [ ] 필드 레벨: `space-y-2`
+- [ ] 카드 패딩: `p-4` 또는 `p-6`
+
+### 버튼
+- [ ] 표준 variants 사용 (`default`, `outline`, `ghost`)
+- [ ] 표준 크기: `h-10` (기본), `h-9` (작음), `h-8` (아이콘)
+- [ ] 텍스트: `text-sm font-medium`
+- [ ] 아이콘 + 텍스트: `gap-2`
+
+### 입력 필드
+- [ ] 높이: `h-10`
+- [ ] 텍스트: `text-sm`
+- [ ] Label과 Input `htmlFor`/`id` 연결
+- [ ] `space-y-2` 구조
+
+### 테이블
+- [ ] `rounded-lg border bg-card shadow-sm`
+- [ ] 헤더: `h-12 text-sm font-semibold bg-muted/50`
+- [ ] 데이터 행: `h-16 text-sm`
+- [ ] Hover: `hover:bg-muted/50`
+
+### 반응형
+- [ ] 모바일 우선 디자인
+- [ ] `sm:`, `md:`, `lg:` 브레이크포인트 사용
+- [ ] `flex-col sm:flex-row` 패턴
+
+### 접근성
+- [ ] Label `htmlFor` 속성
+- [ ] Input `id` 속성
+- [ ] 버튼 `aria-label`
+- [ ] Switch `aria-label`
+
+## 마이그레이션 절차
+
+1. **페이지 컴포넌트 수정** (`page.tsx`)
+ - 레이아웃 구조 변경
+ - 색상 CSS Variables로 변경
+ - 페이지 헤더 표준화
+
+2. **Toolbar 컴포넌트 수정**
+ - 검색 영역 스타일 통일
+ - 버튼 스타일 표준화
+ - 반응형 레이아웃 적용
+
+3. **Table 컴포넌트 수정**
+ - 테이블 컨테이너 스타일 통일
+ - 헤더/데이터 행 높이 표준화
+ - 로딩/Empty State 표준화
+
+4. **Form 컴포넌트 수정** (있는 경우)
+ - 필드 구조 표준화
+ - 라벨과 입력 필드 연결
+ - 에러 메시지 스타일 통일
+
+5. **Modal 컴포넌트 수정** (있는 경우)
+ - Dialog 표준 패턴 적용
+ - 반응형 크기 (`max-w-[95vw] sm:max-w-[500px]`)
+ - 버튼 스타일 표준화
+
+6. **린트 에러 확인**
+ ```bash
+ # 수정한 파일들 확인
+ npm run lint
+ ```
+
+7. **테스트**
+ - 기능 동작 확인
+ - 반응형 확인 (모바일/태블릿/데스크톱)
+ - 다크모드 확인 (있는 경우)
+
+## 참고 파일
+
+### 완성된 예시
+- `frontend/app/(main)/admin/userMng/page.tsx`
+- `frontend/components/admin/UserToolbar.tsx`
+- `frontend/components/admin/UserTable.tsx`
+- `frontend/components/admin/UserManagement.tsx`
+
+### 스타일 가이드
+- `.cursorrules` - 전체 스타일 규칙
+- Section 1-21: 각 스타일 요소별 상세 가이드
+
diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/batchmng/page.tsx
index 184ae578..46aedf1f 100644
--- a/frontend/app/(main)/admin/batchmng/page.tsx
+++ b/frontend/app/(main)/admin/batchmng/page.tsx
@@ -1,17 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@/components/ui/table";
import {
Plus,
Search,
@@ -26,6 +17,7 @@ import {
BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
+import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function BatchManagementPage() {
const router = useRouter();
@@ -178,187 +170,198 @@ export default function BatchManagementPage() {
};
return (
-
- {/* 헤더 */}
-
-
-
배치 관리
-
데이터베이스 간 배치 작업을 관리합니다.
+
+
+ {/* 페이지 헤더 */}
+
+
배치 관리
+
데이터베이스 간 배치 작업을 관리합니다.
-
-
- 배치 추가
-
-
- {/* 검색 및 필터 */}
-
-
-
-
-
-
handleSearch(e.target.value)}
- className="pl-10"
- />
+ {/* 검색 및 액션 영역 */}
+
+ {/* 검색 영역 */}
+
-
-
- {/* 배치 목록 */}
-
-
-
- 배치 목록 ({batchConfigs.length}개)
- {loading && }
-
-
-
- {batchConfigs.length === 0 ? (
-
-
-
배치가 없습니다
-
- {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
-
+ {/* 액션 버튼 영역 */}
+
+
+ 총{" "}
+
+ {batchConfigs.length.toLocaleString()}
+ {" "}
+ 건
+
+
+
+ 배치 추가
+
+
+
+
+ {/* 배치 목록 */}
+ {batchConfigs.length === 0 ? (
+
+
+
+
+
배치가 없습니다
+
+ {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
+
+
{!searchTerm && (
- 첫 번째 배치 추가
+ 첫 번째 배치 추가
)}
- ) : (
-
- {batchConfigs.map((batch) => (
- {
- console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
- toggleBatchStatus(batchId, currentStatus);
- }}
- onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
- onDelete={deleteBatch}
- getMappingSummary={getMappingSummary}
- />
- ))}
-
- )}
-
-
-
- {/* 페이지네이션 */}
- {totalPages > 1 && (
-
-
setCurrentPage(prev => Math.max(1, prev - 1))}
- disabled={currentPage === 1}
- >
- 이전
-
-
-
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
- const pageNum = i + 1;
- return (
- setCurrentPage(pageNum)}
- >
- {pageNum}
-
- );
- })}
-
-
setCurrentPage(prev => Math.min(totalPages, prev + 1))}
- disabled={currentPage === totalPages}
- >
- 다음
-
-
- )}
+ ) : (
+
+ {batchConfigs.map((batch) => (
+ {
+ toggleBatchStatus(batchId, currentStatus);
+ }}
+ onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
+ onDelete={deleteBatch}
+ getMappingSummary={getMappingSummary}
+ />
+ ))}
+
+ )}
- {/* 배치 타입 선택 모달 */}
- {isBatchTypeModalOpen && (
-
-
-
- 배치 타입 선택
-
-
-
- {/* DB → DB */}
-
handleBatchTypeSelect('db-to-db')}
- >
-
-
-
DB → DB
-
데이터베이스 간 데이터 동기화
-
+ {/* 페이지네이션 */}
+ {totalPages > 1 && (
+
+
setCurrentPage(prev => Math.max(1, prev - 1))}
+ disabled={currentPage === 1}
+ className="h-10 text-sm font-medium"
+ >
+ 이전
+
+
+
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
+ const pageNum = i + 1;
+ return (
+ setCurrentPage(pageNum)}
+ className="h-10 min-w-[40px] text-sm"
+ >
+ {pageNum}
+
+ );
+ })}
+
+
+
setCurrentPage(prev => Math.min(totalPages, prev + 1))}
+ disabled={currentPage === totalPages}
+ className="h-10 text-sm font-medium"
+ >
+ 다음
+
+
+ )}
+
+ {/* 배치 타입 선택 모달 */}
+ {isBatchTypeModalOpen && (
+
+
+
+
배치 타입 선택
+
+
+ {/* DB → DB */}
+
handleBatchTypeSelect('db-to-db')}
+ >
+
+
+ →
+
+
+
+
DB → DB
+
데이터베이스 간 데이터 동기화
+
+
+
+ {/* REST API → DB */}
+
handleBatchTypeSelect('restapi-to-db')}
+ >
+
+ 🌐
+ →
+
+
+
+
REST API → DB
+
REST API에서 데이터베이스로 데이터 수집
+
+
- {/* REST API → DB */}
-
handleBatchTypeSelect('restapi-to-db')}
- >
-
-
-
REST API → DB
-
REST API에서 데이터베이스로 데이터 수집
-
+
+ setIsBatchTypeModalOpen(false)}
+ className="h-10 text-sm font-medium"
+ >
+ 취소
+
+
+
+ )}
+
-
- setIsBatchTypeModalOpen(false)}
- >
- 취소
-
-
-
-
-
- )}
+ {/* Scroll to Top 버튼 */}
+
);
}
\ No newline at end of file
diff --git a/frontend/app/(main)/admin/commonCode/page.tsx b/frontend/app/(main)/admin/commonCode/page.tsx
index bdb435f7..9c1bf507 100644
--- a/frontend/app/(main)/admin/commonCode/page.tsx
+++ b/frontend/app/(main)/admin/commonCode/page.tsx
@@ -1,59 +1,49 @@
"use client";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
-// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
+import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function CommonCodeManagementPage() {
- // const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return (
-
-
- {/* 페이지 제목 */}
-
-
-
공통코드 관리
-
시스템에서 사용하는 공통코드를 관리합니다
+
+
+ {/* 페이지 헤더 */}
+
+
공통코드 관리
+
시스템에서 사용하는 공통코드를 관리합니다
+
+
+ {/* 메인 콘텐츠 - 좌우 레이아웃 */}
+
+ {/* 좌측: 카테고리 패널 */}
+
+
+ {/* 우측: 코드 상세 패널 */}
+
+
+
+ 코드 상세 정보
+ {selectedCategoryCode && (
+ ({selectedCategoryCode})
+ )}
+
+
+
-
- {/* 메인 콘텐츠 */}
- {/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */}
-
- {/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
-
-
-
- 📂 코드 카테고리
-
-
-
-
-
-
-
- {/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */}
-
-
-
-
- 📋 코드 상세 정보
- {selectedCategoryCode && (
- ({selectedCategoryCode})
- )}
-
-
-
-
-
-
-
-
+
+ {/* Scroll to Top 버튼 */}
+
);
}
diff --git a/frontend/app/(main)/admin/company/page.tsx b/frontend/app/(main)/admin/company/page.tsx
index c24a3e10..c24afc7a 100644
--- a/frontend/app/(main)/admin/company/page.tsx
+++ b/frontend/app/(main)/admin/company/page.tsx
@@ -1,21 +1,25 @@
import { CompanyManagement } from "@/components/admin/CompanyManagement";
+import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
* 회사 관리 페이지
*/
export default function CompanyPage() {
return (
-
-
- {/* 페이지 제목 */}
-
-
-
회사 관리
-
시스템에서 사용하는 회사 정보를 관리합니다
-
+
+
+ {/* 페이지 헤더 */}
+
+
회사 관리
+
시스템에서 사용하는 회사 정보를 관리합니다
+
+ {/* 메인 컨텐츠 */}
+
+ {/* Scroll to Top 버튼 */}
+
);
}
diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx
index d1ca6125..16e2ed6a 100644
--- a/frontend/app/(main)/admin/dashboard/page.tsx
+++ b/frontend/app/(main)/admin/dashboard/page.tsx
@@ -1,13 +1,11 @@
"use client";
-import React, { useState, useEffect } from "react";
+import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
-import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
@@ -25,8 +23,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { Pagination, PaginationInfo } from "@/components/common/Pagination";
+import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react";
/**
* 대시보드 관리 페이지
@@ -35,27 +34,38 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu
*/
export default function DashboardListPage() {
const router = useRouter();
+ const { toast } = useToast();
const [dashboards, setDashboards] = useState
([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
- const [error, setError] = useState(null);
+
+ // 페이지네이션 상태
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
- const [successDialogOpen, setSuccessDialogOpen] = useState(false);
- const [successMessage, setSuccessMessage] = useState("");
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
- setError(null);
- const result = await dashboardApi.getMyDashboards({ search: searchTerm });
+ const result = await dashboardApi.getMyDashboards({
+ search: searchTerm,
+ page: currentPage,
+ limit: pageSize,
+ });
setDashboards(result.dashboards);
+ setTotalCount(result.pagination.total);
} catch (err) {
console.error("Failed to load dashboards:", err);
- setError("대시보드 목록을 불러오는데 실패했습니다.");
+ toast({
+ title: "오류",
+ description: "대시보드 목록을 불러오는데 실패했습니다.",
+ variant: "destructive",
+ });
} finally {
setLoading(false);
}
@@ -63,7 +73,29 @@ export default function DashboardListPage() {
useEffect(() => {
loadDashboards();
- }, [searchTerm]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchTerm, currentPage, pageSize]);
+
+ // 페이지네이션 정보 계산
+ const paginationInfo: PaginationInfo = {
+ currentPage,
+ totalPages: Math.ceil(totalCount / pageSize),
+ totalItems: totalCount,
+ itemsPerPage: pageSize,
+ startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
+ endItem: Math.min(currentPage * pageSize, totalCount),
+ };
+
+ // 페이지 변경 핸들러
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ // 페이지 크기 변경 핸들러
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size);
+ setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
+ };
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
@@ -79,37 +111,48 @@ export default function DashboardListPage() {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
- setSuccessMessage("대시보드가 삭제되었습니다.");
- setSuccessDialogOpen(true);
+ toast({
+ title: "성공",
+ description: "대시보드가 삭제되었습니다.",
+ });
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
- setError("대시보드 삭제에 실패했습니다.");
+ toast({
+ title: "오류",
+ description: "대시보드 삭제에 실패했습니다.",
+ variant: "destructive",
+ });
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
- // 전체 대시보드 정보(요소 포함)를 가져오기
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
- const newDashboard = await dashboardApi.createDashboard({
+ await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
elements: fullDashboard.elements || [],
isPublic: false,
tags: fullDashboard.tags,
category: fullDashboard.category,
- settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사
+ settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
+ });
+ toast({
+ title: "성공",
+ description: "대시보드가 복사되었습니다.",
});
- setSuccessMessage("대시보드가 복사되었습니다.");
- setSuccessDialogOpen(true);
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
- setError("대시보드 복사에 실패했습니다.");
+ toast({
+ title: "오류",
+ description: "대시보드 복사에 실패했습니다.",
+ variant: "destructive",
+ });
}
};
@@ -119,109 +162,99 @@ export default function DashboardListPage() {
year: "numeric",
month: "2-digit",
day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
});
};
if (loading) {
return (
-
+
-
로딩 중...
-
대시보드 목록을 불러오고 있습니다
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
);
}
return (
-
-
- {/* 헤더 */}
-
-
대시보드 관리
-
대시보드를 생성하고 관리할 수 있습니다
+
+
+ {/* 페이지 헤더 */}
+
+
대시보드 관리
+
대시보드를 생성하고 관리할 수 있습니다
- {/* 액션 바 */}
-
-
-
+ {/* 검색 및 액션 */}
+
+
+
setSearchTerm(e.target.value)}
- className="pl-9"
+ className="h-10 pl-10 text-sm"
/>
-
router.push("/admin/dashboard/new")} className="gap-2">
+ router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
새 대시보드 생성
- {/* 에러 메시지 */}
- {error && (
-
- {error}
-
- )}
-
{/* 대시보드 목록 */}
{dashboards.length === 0 ? (
-
-
-
+
+
-
대시보드가 없습니다
-
첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요
-
router.push("/admin/dashboard/new")} className="gap-2">
- 새 대시보드 생성
-
-
+
) : (
-
+
-
- 제목
- 설명
- 생성일
- 수정일
- 작업
+
+ 제목
+ 설명
+ 생성일
+ 수정일
+ 작업
{dashboards.map((dashboard) => (
-
- {dashboard.title}
-
+
+ {dashboard.title}
+
{dashboard.description || "-"}
- {formatDate(dashboard.createdAt)}
- {formatDate(dashboard.updatedAt)}
-
+
+ {formatDate(dashboard.createdAt)}
+
+
+ {formatDate(dashboard.updatedAt)}
+
+
-
+
router.push(`/admin/dashboard/edit/${dashboard.id}`)}
- className="gap-2"
+ className="gap-2 text-sm"
>
편집
- handleCopy(dashboard)} className="gap-2">
+ handleCopy(dashboard)} className="gap-2 text-sm">
복사
handleDeleteClick(dashboard.id, dashboard.title)}
- className="gap-2 text-red-600 focus:text-red-600"
+ className="text-destructive focus:text-destructive gap-2 text-sm"
>
삭제
@@ -233,44 +266,42 @@ export default function DashboardListPage() {
))}
-
+
+ )}
+
+ {/* 페이지네이션 */}
+ {!loading && dashboards.length > 0 && (
+
)}
{/* 삭제 확인 모달 */}
-
+
- 대시보드 삭제
-
+ 대시보드 삭제
+
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
-
- 취소
-
+
+ 취소
+
삭제
-
- {/* 성공 모달 */}
-
-
-
-
-
-
- 완료
- {successMessage}
-
-
- setSuccessDialogOpen(false)}>확인
-
-
-
);
}
diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx
index ff7e5aeb..f8a77e11 100644
--- a/frontend/app/(main)/admin/dataflow/page.tsx
+++ b/frontend/app/(main)/admin/dataflow/page.tsx
@@ -7,6 +7,7 @@ import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ArrowLeft } from "lucide-react";
type Step = "list" | "editor";
@@ -50,17 +51,17 @@ export default function DataFlowPage() {
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
return (
-
+
{/* 에디터 헤더 */}
-
+
목록으로
-
노드 플로우 에디터
-
+
노드 플로우 에디터
+
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
@@ -76,19 +77,20 @@ export default function DataFlowPage() {
}
return (
-
-
- {/* 페이지 제목 */}
-
-
-
제어 관리
-
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
-
+
+
+ {/* 페이지 헤더 */}
+
+
제어 관리
+
노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다
{/* 플로우 목록 */}
+
+ {/* Scroll to Top 버튼 */}
+
);
}
diff --git a/frontend/app/(main)/admin/external-call-configs/page.tsx b/frontend/app/(main)/admin/external-call-configs/page.tsx
index 805220ca..7e433ec7 100644
--- a/frontend/app/(main)/admin/external-call-configs/page.tsx
+++ b/frontend/app/(main)/admin/external-call-configs/page.tsx
@@ -161,205 +161,201 @@ export default function ExternalCallConfigsPage() {
};
return (
-
-
- {/* 페이지 헤더 */}
-
-
-
외부 호출 관리
-
Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.
+
+
+ {/* 페이지 헤더 */}
+
+
외부 호출 관리
+
Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.
-
- 새 외부 호출 추가
-
-
- {/* 검색 및 필터 */}
-
-
-
-
- 검색 및 필터
-
-
-
- {/* 검색 */}
-
-
-
setSearchQuery(e.target.value)}
- onKeyPress={handleSearchKeyPress}
- />
+ {/* 검색 및 필터 영역 */}
+
+ {/* 첫 번째 줄: 검색 + 추가 버튼 */}
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+
+
+ 검색
+
-
-
+
+
+ 새 외부 호출 추가
- {/* 필터 */}
-
-
- 호출 타입
-
- setFilter((prev) => ({
- ...prev,
- call_type: value === "all" ? undefined : value,
- }))
- }
- >
-
-
-
-
- 전체
- {CALL_TYPE_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
+ {/* 두 번째 줄: 필터 */}
+
+
+ setFilter((prev) => ({
+ ...prev,
+ call_type: value === "all" ? undefined : value,
+ }))
+ }
+ >
+
+
+
+
+ 전체
+ {CALL_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
-
- API 타입
-
- setFilter((prev) => ({
- ...prev,
- api_type: value === "all" ? undefined : value,
- }))
- }
- >
-
-
-
-
- 전체
- {API_TYPE_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
+
+ setFilter((prev) => ({
+ ...prev,
+ api_type: value === "all" ? undefined : value,
+ }))
+ }
+ >
+
+
+
+
+ 전체
+ {API_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
-
- 상태
-
- setFilter((prev) => ({
- ...prev,
- is_active: value,
- }))
- }
- >
-
-
-
-
- {ACTIVE_STATUS_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
+
+ setFilter((prev) => ({
+ ...prev,
+ is_active: value,
+ }))
+ }
+ >
+
+
+
+
+ {ACTIVE_STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
-
-
+
- {/* 설정 목록 */}
-
-
- 외부 호출 설정 목록
-
-
+ {/* 설정 목록 */}
+
{loading ? (
// 로딩 상태
-
-
로딩 중...
+
) : configs.length === 0 ? (
// 빈 상태
-
-
-
-
등록된 외부 호출 설정이 없습니다.
-
새 외부 호출을 추가해보세요.
+
+
+
등록된 외부 호출 설정이 없습니다.
+
새 외부 호출을 추가해보세요.
) : (
// 설정 테이블 목록
-
- 설정명
- 호출 타입
- API 타입
- 설명
- 상태
- 생성일
- 작업
+
+ 설정명
+ 호출 타입
+ API 타입
+ 설명
+ 상태
+ 생성일
+ 작업
{configs.map((config) => (
-
- {config.config_name}
-
+
+ {config.config_name}
+
{getCallTypeLabel(config.call_type)}
-
+
{config.api_type ? (
{getApiTypeLabel(config.api_type)}
) : (
- -
+ -
)}
-
+
{config.description ? (
-
+
{config.description}
) : (
- -
+ -
)}
-
+
{config.is_active === "Y" ? "활성" : "비활성"}
-
+
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
-
+
- handleTestConfig(config)} title="테스트">
-
+ handleTestConfig(config)}
+ title="테스트"
+ >
+
- handleEditConfig(config)} title="편집">
-
+ handleEditConfig(config)}
+ title="편집"
+ >
+
handleDeleteConfig(config)}
- className="text-destructive hover:text-destructive"
title="삭제"
>
-
+
@@ -368,8 +364,7 @@ export default function ExternalCallConfigsPage() {
)}
-
-
+
{/* 외부 호출 설정 모달 */}
-
+
- 외부 호출 설정 삭제
-
+ 외부 호출 설정 삭제
+
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다.
-
- 취소
-
+
+
+ 취소
+
+
삭제
diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx
index 802a2fea..3c80ac58 100644
--- a/frontend/app/(main)/admin/external-connections/page.tsx
+++ b/frontend/app/(main)/admin/external-connections/page.tsx
@@ -1,13 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
-import { Plus, Search, Pencil, Trash2, Database, Terminal } from "lucide-react";
+import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
@@ -27,6 +28,9 @@ import {
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
+import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
+
+type ConnectionTabType = "database" | "rest-api";
// DB 타입 매핑
const DB_TYPE_LABELS: Record = {
@@ -47,6 +51,9 @@ const ACTIVE_STATUS_OPTIONS = [
export default function ExternalConnectionsPage() {
const { toast } = useToast();
+ // 탭 상태
+ const [activeTab, setActiveTab] = useState("database");
+
// 상태 관리
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
@@ -220,236 +227,246 @@ export default function ExternalConnectionsPage() {
};
return (
-
-
- {/* 페이지 제목 */}
-
-
-
외부 커넥션 관리
-
외부 데이터베이스 연결 정보를 관리합니다
-
+
+
+ {/* 페이지 헤더 */}
+
+
외부 커넥션 관리
+
외부 데이터베이스 및 REST API 연결 정보를 관리합니다
- {/* 검색 및 필터 */}
-
-
-
-
- {/* 검색 */}
-
-
-
setSearchTerm(e.target.value)}
- className="w-64 pl-10"
- />
+ {/* 탭 */}
+
setActiveTab(value as ConnectionTabType)}>
+
+
+
+ 데이터베이스 연결
+
+
+
+ REST API 연결
+
+
+
+ {/* 데이터베이스 연결 탭 */}
+
+ {/* 검색 및 필터 */}
+
+
+ {/* 검색 */}
+
+
+ setSearchTerm(e.target.value)}
+ className="h-10 pl-10 text-sm"
+ />
+
+
+ {/* DB 타입 필터 */}
+
+
+
+
+
+ {supportedDbTypes.map((type) => (
+
+ {type.label}
+
+ ))}
+
+
+
+ {/* 활성 상태 필터 */}
+
+
+
+
+
+ {ACTIVE_STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
- {/* DB 타입 필터 */}
-
-
-
-
-
- {supportedDbTypes.map((type) => (
-
- {type.label}
-
- ))}
-
-
-
- {/* 활성 상태 필터 */}
-
-
-
-
-
- {ACTIVE_STATUS_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
-
- {/* 추가 버튼 */}
-
- 새 연결 추가
-
-
-
-
-
- {/* 연결 목록 */}
- {loading ? (
-
- ) : connections.length === 0 ? (
-
-
-
-
-
등록된 연결이 없습니다
-
새 외부 데이터베이스 연결을 추가해보세요.
-
- 첫 번째 연결 추가
+ {/* 추가 버튼 */}
+
+
+ 새 연결 추가
-
-
- ) : (
-
-
-
-
-
- 연결명
- DB 타입
- 호스트:포트
- 데이터베이스
- 사용자
- 상태
- 생성일
- 연결 테스트
- 작업
-
-
-
- {connections.map((connection) => (
-
-
- {connection.connection_name}
-
-
-
- {DB_TYPE_LABELS[connection.db_type] || connection.db_type}
-
-
-
- {connection.host}:{connection.port}
-
- {connection.database_name}
- {connection.username}
-
-
- {connection.is_active === "Y" ? "활성" : "비활성"}
-
-
-
- {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
-
-
-
- handleTestConnection(connection)}
- disabled={testingConnections.has(connection.id!)}
- className="h-7 px-2 text-xs"
- >
- {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
-
- {testResults.has(connection.id!) && (
-
- {testResults.get(connection.id!) ? "성공" : "실패"}
-
- )}
-
-
-
-
-
{
- console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
- setSelectedConnection(connection);
- setSqlModalOpen(true);
- }}
- className="h-8 w-8 p-0"
- title="SQL 쿼리 실행"
- >
-
-
-
handleEditConnection(connection)}
- className="h-8 w-8 p-0"
- >
-
-
-
handleDeleteConnection(connection)}
- className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
- >
-
-
-
-
-
- ))}
-
-
-
-
- )}
- {/* 연결 설정 모달 */}
- {isModalOpen && (
-
type.value !== "ALL")}
- />
- )}
+ {/* 연결 목록 */}
+ {loading ? (
+
+ ) : connections.length === 0 ? (
+
+ ) : (
+
+
+
+
+ 연결명
+ DB 타입
+ 호스트:포트
+ 데이터베이스
+ 사용자
+ 상태
+ 생성일
+ 연결 테스트
+ 작업
+
+
+
+ {connections.map((connection) => (
+
+
+ {connection.connection_name}
+
+
+
+ {DB_TYPE_LABELS[connection.db_type] || connection.db_type}
+
+
+
+ {connection.host}:{connection.port}
+
+ {connection.database_name}
+ {connection.username}
+
+
+ {connection.is_active === "Y" ? "활성" : "비활성"}
+
+
+
+ {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
+
+
+
+ handleTestConnection(connection)}
+ disabled={testingConnections.has(connection.id!)}
+ className="h-9 text-sm"
+ >
+ {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
+
+ {testResults.has(connection.id!) && (
+
+ {testResults.get(connection.id!) ? "성공" : "실패"}
+
+ )}
+
+
+
+
+
{
+ console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
+ setSelectedConnection(connection);
+ setSqlModalOpen(true);
+ }}
+ className="h-8 w-8"
+ title="SQL 쿼리 실행"
+ >
+
+
+
handleEditConnection(connection)}
+ className="h-8 w-8"
+ >
+
+
+
handleDeleteConnection(connection)}
+ className="h-8 w-8 text-destructive hover:bg-destructive/10"
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
- {/* 삭제 확인 다이얼로그 */}
-
-
-
- 연결 삭제 확인
-
- "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
-
- 이 작업은 되돌릴 수 없습니다.
-
-
-
- 취소
-
- 삭제
-
-
-
-
+ {/* 연결 설정 모달 */}
+ {isModalOpen && (
+ type.value !== "ALL")}
+ />
+ )}
- {/* SQL 쿼리 모달 */}
- {selectedConnection && (
- {
- setSqlModalOpen(false);
- setSelectedConnection(null);
- }}
- connectionId={selectedConnection.id!}
- connectionName={selectedConnection.connection_name}
- />
- )}
+ {/* 삭제 확인 다이얼로그 */}
+
+
+
+ 연결 삭제 확인
+
+ "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
+
+ 이 작업은 되돌릴 수 없습니다.
+
+
+
+
+ 취소
+
+
+ 삭제
+
+
+
+
+
+ {/* SQL 쿼리 모달 */}
+ {selectedConnection && (
+ {
+ setSqlModalOpen(false);
+ setSelectedConnection(null);
+ }}
+ connectionId={selectedConnection.id!}
+ connectionName={selectedConnection.connection_name}
+ />
+ )}
+
+
+ {/* REST API 연결 탭 */}
+
+
+
+
);
diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx
index 77d42718..a311bc63 100644
--- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx
+++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx
@@ -73,7 +73,9 @@ export default function FlowEditorPage() {
// 플로우 정의 로드
const flowRes = await getFlowDefinition(flowId);
if (flowRes.success && flowRes.data) {
- setFlowDefinition(flowRes.data);
+ console.log("🔍 Flow Definition loaded:", flowRes.data);
+ console.log("📋 Table Name:", flowRes.data.definition?.tableName);
+ setFlowDefinition(flowRes.data.definition);
}
// 단계 로드
@@ -314,6 +316,9 @@ export default function FlowEditorPage() {
setSelectedStep(null)}
onUpdate={loadFlowData}
/>
diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx
index 999fb6fa..e8166662 100644
--- a/frontend/app/(main)/admin/flow-management/page.tsx
+++ b/frontend/app/(main)/admin/flow-management/page.tsx
@@ -9,9 +9,8 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react";
+import { Plus, Edit2, Trash2, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
@@ -27,6 +26,12 @@ import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
import { FlowDefinition } from "@/types/flow";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { tableManagementApi } from "@/lib/api/tableManagement";
+import { ScrollToTop } from "@/components/common/ScrollToTop";
export default function FlowManagementPage() {
const router = useRouter();
@@ -39,6 +44,19 @@ export default function FlowManagementPage() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState(null);
+ // 테이블 목록 관련 상태
+ const [tableList, setTableList] = useState>(
+ [],
+ );
+ const [loadingTables, setLoadingTables] = useState(false);
+ const [openTableCombobox, setOpenTableCombobox] = useState(false);
+ const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
+ const [externalConnections, setExternalConnections] = useState<
+ Array<{ id: number; connection_name: string; db_type: string }>
+ >([]);
+ const [externalTableList, setExternalTableList] = useState([]);
+ const [loadingExternalTables, setLoadingExternalTables] = useState(false);
+
// 생성 폼 상태
const [formData, setFormData] = useState({
name: "",
@@ -60,10 +78,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
- } catch (error: any) {
+ } catch (error) {
toast({
title: "오류 발생",
- description: error.message,
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
@@ -73,11 +91,113 @@ export default function FlowManagementPage() {
useEffect(() => {
loadFlows();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ // 테이블 목록 로드 (내부 DB)
+ useEffect(() => {
+ const loadTables = async () => {
+ try {
+ setLoadingTables(true);
+ const response = await tableManagementApi.getTableList();
+ if (response.success && response.data) {
+ setTableList(response.data);
+ }
+ } catch (error) {
+ console.error("Failed to load tables:", error);
+ } finally {
+ setLoadingTables(false);
+ }
+ };
+ loadTables();
+ }, []);
+
+ // 외부 DB 연결 목록 로드
+ useEffect(() => {
+ const loadConnections = async () => {
+ try {
+ const token = localStorage.getItem("authToken");
+ if (!token) {
+ console.warn("No auth token found");
+ return;
+ }
+
+ const response = await fetch("/api/external-db-connections/control/active", {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (response && response.ok) {
+ const data = await response.json();
+ if (data.success && data.data) {
+ // 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
+ const filtered = data.data.filter(
+ (conn: { connection_name: string }) =>
+ !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
+ );
+ setExternalConnections(filtered);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load external connections:", error);
+ setExternalConnections([]);
+ }
+ };
+ loadConnections();
+ }, []);
+
+ // 외부 DB 테이블 목록 로드
+ useEffect(() => {
+ if (selectedDbSource === "internal" || !selectedDbSource) {
+ setExternalTableList([]);
+ return;
+ }
+
+ const loadExternalTables = async () => {
+ try {
+ setLoadingExternalTables(true);
+ const token = localStorage.getItem("authToken");
+
+ const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (response && response.ok) {
+ const data = await response.json();
+ if (data.success && data.data) {
+ const tables = Array.isArray(data.data) ? data.data : [];
+ const tableNames = tables
+ .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) =>
+ typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
+ )
+ .filter(Boolean);
+ setExternalTableList(tableNames);
+ } else {
+ setExternalTableList([]);
+ }
+ } else {
+ setExternalTableList([]);
+ }
+ } catch (error) {
+ console.error("외부 DB 테이블 목록 조회 오류:", error);
+ setExternalTableList([]);
+ } finally {
+ setLoadingExternalTables(false);
+ }
+ };
+
+ loadExternalTables();
+ }, [selectedDbSource]);
+
// 플로우 생성
const handleCreate = async () => {
+ console.log("🚀 handleCreate called with formData:", formData);
+
if (!formData.name || !formData.tableName) {
+ console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
toast({
title: "입력 오류",
description: "플로우 이름과 테이블 이름은 필수입니다.",
@@ -87,7 +207,15 @@ export default function FlowManagementPage() {
}
try {
- const response = await createFlowDefinition(formData);
+ // DB 소스 정보 추가
+ const requestData = {
+ ...formData,
+ dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
+ dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
+ };
+
+ console.log("✅ Calling createFlowDefinition with:", requestData);
+ const response = await createFlowDefinition(requestData);
if (response.success && response.data) {
toast({
title: "생성 완료",
@@ -95,6 +223,7 @@ export default function FlowManagementPage() {
});
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
+ setSelectedDbSource("internal");
loadFlows();
} else {
toast({
@@ -103,10 +232,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
- } catch (error: any) {
+ } catch (error) {
toast({
title: "오류 발생",
- description: error.message,
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
}
@@ -133,10 +262,10 @@ export default function FlowManagementPage() {
variant: "destructive",
});
}
- } catch (error: any) {
+ } catch (error) {
toast({
title: "오류 발생",
- description: error.message,
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
variant: "destructive",
});
}
@@ -148,213 +277,342 @@ export default function FlowManagementPage() {
};
return (
-
- {/* 헤더 */}
-
-
-
-
- 플로우 관리
-
-
업무 프로세스 플로우를 생성하고 관리합니다
+
+
+ {/* 페이지 헤더 */}
+
+
플로우 관리
+
업무 프로세스 플로우를 생성하고 관리합니다
-
setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
-
- 새 플로우 생성
- 생성
-
-
- {/* 플로우 카드 목록 */}
- {loading ? (
-
-
로딩 중...
+ {/* 액션 버튼 영역 */}
+
+
setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
+ 새 플로우 생성
+
- ) : flows.length === 0 ? (
-
-
-
- 생성된 플로우가 없습니다
- setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
- 첫 플로우 만들기
-
-
-
- ) : (
-
- {flows.map((flow) => (
-
handleEdit(flow.id)}
- >
-
-
+
+ {/* 플로우 카드 목록 */}
+ {loading ? (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+ ))}
+
+ ) : flows.length === 0 ? (
+
+
+
+
+
+
생성된 플로우가 없습니다
+
+ 새 플로우를 생성하여 업무 프로세스를 관리해보세요.
+
+
setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
+ 첫 플로우 만들기
+
+
+
+ ) : (
+
+ {flows.map((flow) => (
+
handleEdit(flow.id)}
+ >
+ {/* 헤더 */}
+
-
- {flow.name}
+
+
{flow.name}
{flow.isActive && (
-
- 활성
-
+ 활성
)}
-
-
- {flow.description || "설명 없음"}
-
-
-
-
-
-
-
-
-
- 생성자: {flow.createdBy}
-
-
-
- {new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
+
+
{flow.description || "설명 없음"}
-
+ {/* 정보 */}
+
+
+
+
+ 생성자: {flow.createdBy}
+
+
+
+
+ {new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
+
+
+
+
+ {/* 액션 */}
+
{
e.stopPropagation();
handleEdit(flow.id);
}}
>
-
+
편집
{
e.stopPropagation();
setSelectedFlow(flow);
setIsDeleteDialogOpen(true);
}}
>
-
+
-
-
- ))}
-
- )}
-
- {/* 생성 다이얼로그 */}
-
-
-
- 새 플로우 생성
-
- 새로운 업무 프로세스 플로우를 생성합니다
-
-
-
-
-
-
- 플로우 이름 *
-
- setFormData({ ...formData, name: e.target.value })}
- placeholder="예: 제품 수명주기 관리"
- className="h-8 text-xs sm:h-10 sm:text-sm"
- />
-
-
-
-
- 연결 테이블 *
-
-
setFormData({ ...formData, tableName: e.target.value })}
- placeholder="예: products"
- className="h-8 text-xs sm:h-10 sm:text-sm"
- />
-
- 플로우가 관리할 데이터 테이블 이름을 입력하세요
-
-
-
-
-
- 설명
-
-
+
+ ))}
+ )}
-
- setIsCreateDialogOpen(false)}
- className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
- >
- 취소
-
-
- 생성
-
-
-
-
+ {/* 생성 다이얼로그 */}
+
+
+
+ 새 플로우 생성
+
+ 새로운 업무 프로세스 플로우를 생성합니다
+
+
- {/* 삭제 확인 다이얼로그 */}
-
-
-
- 플로우 삭제
-
- 정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없습니다.
-
-
+
+
+
+ 플로우 이름 *
+
+ setFormData({ ...formData, name: e.target.value })}
+ placeholder="예: 제품 수명주기 관리"
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+
-
- {
- setIsDeleteDialogOpen(false);
- setSelectedFlow(null);
- }}
- className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
- >
- 취소
-
-
- 삭제
-
-
-
-
+ {/* DB 소스 선택 */}
+
+ 데이터베이스 소스
+ {
+ const dbSource = value === "internal" ? "internal" : parseInt(value);
+ setSelectedDbSource(dbSource);
+ // DB 소스 변경 시 테이블 선택 초기화
+ setFormData({ ...formData, tableName: "" });
+ }}
+ >
+
+