test
This commit is contained in:
commit
b6b42c51ed
|
|
@ -0,0 +1,749 @@
|
||||||
|
---
|
||||||
|
description: 관리자 페이지 표준 스타일 가이드 - shadcn/ui 기반 일관된 디자인 시스템
|
||||||
|
globs: **/app/(main)/admin/**/*.tsx,**/components/admin/**/*.tsx
|
||||||
|
---
|
||||||
|
|
||||||
|
# 관리자 페이지 표준 스타일 가이드
|
||||||
|
|
||||||
|
이 가이드는 관리자 페이지의 일관된 UI/UX를 위한 표준 스타일 규칙입니다.
|
||||||
|
모든 관리자 페이지는 이 가이드를 따라야 합니다.
|
||||||
|
|
||||||
|
## 1. 페이지 레이아웃 구조
|
||||||
|
|
||||||
|
### 기본 페이지 템플릿
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function AdminPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">페이지 설명</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<MainComponent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
- 최상위: `flex min-h-screen flex-col bg-background`
|
||||||
|
- 컨텐츠 영역: `space-y-6 p-6` (24px 좌우 여백, 24px 간격)
|
||||||
|
- 헤더 구분선: `border-b pb-4` (테두리 박스 사용 금지)
|
||||||
|
- Scroll to Top: 모든 관리자 페이지에 포함
|
||||||
|
|
||||||
|
## 2. Color System (색상 시스템)
|
||||||
|
|
||||||
|
### CSS Variables 사용 (하드코딩 금지)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 잘못된 예시
|
||||||
|
<div className="bg-gray-50 text-gray-900 border-gray-200">
|
||||||
|
|
||||||
|
// ✅ 올바른 예시
|
||||||
|
<div className="bg-background text-foreground border-border">
|
||||||
|
<div className="bg-card text-card-foreground">
|
||||||
|
<div className="bg-muted text-muted-foreground">
|
||||||
|
```
|
||||||
|
|
||||||
|
**표준 색상 토큰:**
|
||||||
|
|
||||||
|
- `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
|
||||||
|
// 페이지 제목
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
|
||||||
|
// 섹션 제목
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
|
||||||
|
// 본문 텍스트
|
||||||
|
<p className="text-sm">
|
||||||
|
|
||||||
|
// 보조 텍스트
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Spacing System (간격)
|
||||||
|
|
||||||
|
### 일관된 간격 (4px 기준)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 페이지 레벨 간격
|
||||||
|
<div className="space-y-6"> // 24px
|
||||||
|
|
||||||
|
// 섹션 레벨 간격
|
||||||
|
<div className="space-y-4"> // 16px
|
||||||
|
|
||||||
|
// 필드 레벨 간격
|
||||||
|
<div className="space-y-2"> // 8px
|
||||||
|
|
||||||
|
// 패딩
|
||||||
|
<div className="p-6"> // 24px (카드)
|
||||||
|
<div className="p-4"> // 16px (내부 섹션)
|
||||||
|
|
||||||
|
// 갭
|
||||||
|
<div className="gap-4"> // 16px (flex/grid)
|
||||||
|
<div className="gap-2"> // 8px (버튼 그룹)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 검색 툴바 (Toolbar)
|
||||||
|
|
||||||
|
### 패턴 A: 통합 검색 영역 (권장)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 및 액션 영역 */}
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
{/* 통합 검색 */}
|
||||||
|
<div className="w-full sm:w-[400px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="통합 검색..." className="h-10 pl-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고급 검색 토글 */}
|
||||||
|
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
고급 검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 영역 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{count.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
건
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고급 검색 옵션 */}
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">설명</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Input placeholder="필드 검색" className="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 패턴 B: 제목 + 검색 + 버튼 한 줄 (공간 효율적)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
/* 상단 헤더: 제목 + 검색 + 버튼 */
|
||||||
|
}
|
||||||
|
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
{/* 왼쪽: 제목 */}
|
||||||
|
<h2 className="text-xl font-semibold">페이지 제목</h2>
|
||||||
|
|
||||||
|
{/* 오른쪽: 검색 + 버튼 */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
{/* 필터 선택 */}
|
||||||
|
<div className="w-full sm:w-[160px]">
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="h-10">
|
||||||
|
<SelectValue placeholder="필터" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="w-full sm:w-[240px]">
|
||||||
|
<Input placeholder="검색..." className="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 초기화 버튼 */}
|
||||||
|
<Button variant="outline" className="h-10 text-sm font-medium">
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 주요 액션 버튼 */}
|
||||||
|
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
등록
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 조건부 버튼 (선택 시) */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Button variant="destructive" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
삭제 ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
- ❌ 검색 영역에 박스/테두리 사용 금지
|
||||||
|
- ✅ 검색창 권장 너비: `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 액션
|
||||||
|
<Button variant="default" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
등록
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Secondary 액션
|
||||||
|
<Button variant="outline" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Ghost 버튼 (아이콘 전용)
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Destructive
|
||||||
|
<Button variant="destructive" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**표준 크기:**
|
||||||
|
|
||||||
|
- `h-10`: 기본 버튼 (40px)
|
||||||
|
- `h-9`: 작은 버튼 (36px)
|
||||||
|
- `h-8`: 아이콘 버튼 (32px)
|
||||||
|
|
||||||
|
**아이콘 크기:**
|
||||||
|
|
||||||
|
- `h-4 w-4`: 버튼 내 아이콘 (16px)
|
||||||
|
|
||||||
|
## 7. Input (입력 필드)
|
||||||
|
|
||||||
|
### 표준 Input 스타일
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 기본
|
||||||
|
<Input placeholder="입력..." className="h-10 text-sm" />
|
||||||
|
|
||||||
|
// 검색 (아이콘 포함)
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="검색..." className="h-10 pl-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 로딩/액티브
|
||||||
|
<Input className="h-10 text-sm border-primary ring-2 ring-primary/20" />
|
||||||
|
|
||||||
|
// 비활성화
|
||||||
|
<Input disabled className="h-10 text-sm cursor-not-allowed bg-muted text-muted-foreground" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
- 높이: `h-10` (40px)
|
||||||
|
- 텍스트: `text-sm`
|
||||||
|
- 포커스: 자동 적용 (`ring-2 ring-ring`)
|
||||||
|
|
||||||
|
## 8. Table & Card (테이블과 카드)
|
||||||
|
|
||||||
|
### 반응형 테이블/카드 구조
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 실제 데이터 렌더링
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
|
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">컬럼</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-16 text-sm">데이터</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold">{item.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{item.id}</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={item.active} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">필드</span>
|
||||||
|
<span className="font-medium">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
액션
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블 표준:**
|
||||||
|
|
||||||
|
- 헤더: `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
|
||||||
|
// 테이블 스켈레톤 (데스크톱)
|
||||||
|
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>...</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index} className="border-b">
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 카드 스켈레톤 (모바일/태블릿)
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-11 animate-pulse rounded-full bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Empty States (빈 상태)
|
||||||
|
|
||||||
|
### 표준 Empty State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Error States (에러 상태)
|
||||||
|
|
||||||
|
### 표준 에러 메시지
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-destructive">
|
||||||
|
오류가 발생했습니다
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-destructive transition-colors hover:text-destructive/80"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm text-destructive/80">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Responsive Design (반응형)
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
- `sm`: 640px (모바일 가로/태블릿)
|
||||||
|
- `md`: 768px (태블릿)
|
||||||
|
- `lg`: 1024px (노트북)
|
||||||
|
- `xl`: 1280px (데스크톱)
|
||||||
|
|
||||||
|
### 모바일 우선 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 레이아웃
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|
||||||
|
// 그리드
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
|
// 검색창
|
||||||
|
<div className="w-full sm:w-[400px]">
|
||||||
|
|
||||||
|
// 테이블/카드 전환
|
||||||
|
<div className="hidden lg:block"> {/* 데스크톱 테이블 */}
|
||||||
|
<div className="lg:hidden"> {/* 모바일 카드 */}
|
||||||
|
|
||||||
|
// 간격
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="gap-2 sm:gap-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. 좌우 레이아웃 (Side-by-Side Layout)
|
||||||
|
|
||||||
|
### 사이드바 + 메인 영역 구조
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex h-full gap-6">
|
||||||
|
{/* 좌측 사이드바 (20-30%) */}
|
||||||
|
<div className="w-[20%] border-r pr-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">사이드바 제목</h3>
|
||||||
|
|
||||||
|
{/* 사이드바 컨텐츠 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md">
|
||||||
|
<h4 className="text-sm font-semibold">항목</h4>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">설명</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 메인 영역 (70-80%) */}
|
||||||
|
<div className="w-[80%] pl-0">
|
||||||
|
<div className="flex h-full flex-col space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">메인 제목</h2>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<div className="flex-1 overflow-hidden">{/* 컨텐츠 */}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
- ✅ 좌우 구분: `border-r` 사용 (세로 구분선)
|
||||||
|
- ✅ 간격: `gap-6` (24px)
|
||||||
|
- ✅ 사이드바 패딩: `pr-6` (오른쪽 24px)
|
||||||
|
- ✅ 메인 영역 패딩: `pl-0` (gap으로 간격 확보)
|
||||||
|
- ✅ 비율: 20:80 또는 30:70
|
||||||
|
- ❌ 과도한 구분선 사용 금지 (세로 구분선 1개만)
|
||||||
|
- ❌ 사이드바와 메인 영역 각각에 추가 border 금지
|
||||||
|
|
||||||
|
## 14. Custom Dropdown (커스텀 드롭다운)
|
||||||
|
|
||||||
|
### 커스텀 Select/Dropdown 구조
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
/* 드롭다운 컨테이너 */
|
||||||
|
}
|
||||||
|
<div className="w-full sm:w-[160px]">
|
||||||
|
<div className="company-dropdown relative">
|
||||||
|
{/* 트리거 버튼 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<span className={!value ? "text-muted-foreground" : ""}>
|
||||||
|
{value || "선택하세요"}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 드롭다운 메뉴 */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
|
||||||
|
{/* 검색 (선택사항) */}
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<Input
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 목록 */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setValue(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
- ✅ z-index: `z-[100]` (다른 요소 위에 표시)
|
||||||
|
- ✅ 그림자: `shadow-lg` (명확한 레이어 구분)
|
||||||
|
- ✅ 최소 너비: `min-w-[200px]` (내용이 잘리지 않도록)
|
||||||
|
- ✅ 최대 높이: `max-h-48` (스크롤 가능)
|
||||||
|
- ✅ 애니메이션: 화살표 아이콘 회전 (`rotate-180`)
|
||||||
|
- ✅ 부모 요소: `relative` 클래스 필요
|
||||||
|
- ⚠️ 부모에 `overflow-hidden` 사용 시 드롭다운 잘림 주의
|
||||||
|
|
||||||
|
**드롭다운이 잘릴 때 해결방법:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 부모 요소의 overflow 제거
|
||||||
|
<div className="w-[80%] pl-0"> // overflow-hidden 제거
|
||||||
|
|
||||||
|
// 또는 상단 헤더에 relative 추가
|
||||||
|
<div className="relative flex ..."> // 드롭다운 포지셔닝 기준점
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. Scroll to Top Button
|
||||||
|
|
||||||
|
### 모바일/태블릿 전용 버튼
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
|
// 페이지에 추가
|
||||||
|
<ScrollToTop />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
|
||||||
|
- 데스크톱에서 숨김 (`lg:hidden`)
|
||||||
|
- 스크롤 200px 이상 시 나타남
|
||||||
|
- 부드러운 페이드 인/아웃 애니메이션
|
||||||
|
- 오른쪽 하단 고정 위치
|
||||||
|
- 원형 디자인 (`rounded-full`)
|
||||||
|
|
||||||
|
## 14. Accessibility (접근성)
|
||||||
|
|
||||||
|
### 필수 적용 사항
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Label과 Input 연결
|
||||||
|
<label htmlFor="field-id" className="text-sm font-medium">
|
||||||
|
라벨
|
||||||
|
</label>
|
||||||
|
<Input id="field-id" />
|
||||||
|
|
||||||
|
// 버튼에 aria-label
|
||||||
|
<Button aria-label="설명">
|
||||||
|
<Icon />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Switch에 aria-label
|
||||||
|
<Switch
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={handleChange}
|
||||||
|
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/app/(main)/admin/userMng/page.tsx>) - 기본 페이지 구조
|
||||||
|
- [검색 툴바](mdc:frontend/components/admin/UserToolbar.tsx) - 패턴 A (통합 검색)
|
||||||
|
- [테이블/카드](mdc:frontend/components/admin/UserTable.tsx) - 반응형 테이블/카드
|
||||||
|
- [Scroll to Top](mdc:frontend/components/common/ScrollToTop.tsx) - 스크롤 버튼
|
||||||
|
|
||||||
|
### 고급 패턴
|
||||||
|
|
||||||
|
- [메뉴 관리 페이지](<mdc:frontend/app/(main)/admin/menu/page.tsx>) - 좌우 레이아웃 + 패턴 B (제목+검색+버튼)
|
||||||
|
- [메뉴 관리 컴포넌트](mdc:frontend/components/admin/MenuManagement.tsx) - 커스텀 드롭다운 + 좌우 레이아웃
|
||||||
|
|
@ -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 (기본)
|
||||||
|
<Button variant="default">저장</Button>
|
||||||
|
|
||||||
|
// Secondary
|
||||||
|
<Button variant="secondary">취소</Button>
|
||||||
|
|
||||||
|
// Outline
|
||||||
|
<Button variant="outline">편집</Button>
|
||||||
|
|
||||||
|
// Ghost
|
||||||
|
<Button variant="ghost">닫기</Button>
|
||||||
|
|
||||||
|
// Destructive
|
||||||
|
<Button variant="destructive">삭제</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**버튼 크기:**
|
||||||
|
- `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
|
||||||
|
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||||
|
<CardHeader className="flex flex-col space-y-1.5 p-6">
|
||||||
|
<CardTitle className="text-2xl font-semibold leading-none tracking-tight">제목</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-muted-foreground">설명</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 pt-0">
|
||||||
|
{/* 내용 */}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center p-6 pt-0">
|
||||||
|
{/* 액션 버튼들 */}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Card> // 중첩 금지!
|
||||||
|
<CardContent>내용</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// ❌ Border 안에 Border
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<div className="border rounded-lg p-4"> // 중첩 금지!
|
||||||
|
내용
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ❌ 불필요한 래퍼
|
||||||
|
<div className="border p-4">
|
||||||
|
<div className="bg-card border rounded-lg"> // 중첩 금지!
|
||||||
|
내용
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**허용되는 패턴:**
|
||||||
|
```tsx
|
||||||
|
// ✅ 단일 Card
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>제목</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
내용
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// ✅ 의미적으로 다른 컴포넌트 조합
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Dialog> // Dialog는 별도 UI 레이어
|
||||||
|
<DialogContent>...</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// ✅ 그리드/리스트 내부의 Card들
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card>항목 1</Card>
|
||||||
|
<Card>항목 2</Card>
|
||||||
|
<Card>항목 3</Card>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**예외 상황 (사용자가 명시적으로 요청한 경우만):**
|
||||||
|
- 대시보드에서 섹션별 그룹핑이 필요한 경우
|
||||||
|
- 복잡한 데이터 구조를 시각적으로 구분해야 하는 경우
|
||||||
|
- 드래그앤드롭 등 특수 기능을 위한 경우
|
||||||
|
|
||||||
|
**원칙:**
|
||||||
|
- 심플하고 깔끔한 디자인 유지
|
||||||
|
- 불필요한 시각적 레이어 제거
|
||||||
|
- 사용자가 명시적으로 "박스 안에 박스", "중첩된 카드" 등을 요청하지 않으면 단일 레벨 유지
|
||||||
|
|
||||||
|
### 17. 표준 모달(Dialog) 디자인 패턴
|
||||||
|
|
||||||
|
**프로젝트 표준 모달 구조 (플로우 관리 기준):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
{/* 헤더: 제목 + 설명 */}
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">모달 제목</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
모달에 대한 간단한 설명
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 컨텐츠: 폼 필드들 */}
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 각 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fieldName" className="text-xs sm:text-sm">
|
||||||
|
필드 라벨 *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="fieldName"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="힌트 텍스트"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
도움말 텍스트 (선택사항)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터: 액션 버튼들 */}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
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)
|
||||||
|
- 각 필드는 `<div>` 로 감싸기
|
||||||
|
|
||||||
|
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
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">작업 확인</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
정말로 이 작업을 수행하시겠습니까?
|
||||||
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**원칙:**
|
||||||
|
- 모든 모달은 모바일 우선 반응형 디자인
|
||||||
|
- 일관된 크기, 간격, 폰트 크기 사용
|
||||||
|
- 사용자가 다른 크기를 명시하지 않으면 `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("");
|
||||||
|
|
||||||
|
// 렌더링
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{value
|
||||||
|
? items.find((item) => item.value === value)?.label
|
||||||
|
: "항목 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="검색..."
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
|
항목을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
setValue(currentValue === value ? "" : currentValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === item.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
**복잡한 데이터 표시 (라벨 + 설명):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CommandItem
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
setValue(currentValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === item.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-[10px] text-gray-500">{item.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수 적용 사항:**
|
||||||
|
|
||||||
|
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
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={loading}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{loading ? "로딩 중..." : value ? "선택됨" : "항목 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**일반 Select vs Combobox 선택 기준:**
|
||||||
|
|
||||||
|
| 상황 | 컴포넌트 | 이유 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 항목 5개 이하 | `<Select>` | 검색 불필요 |
|
||||||
|
| 항목 5개 초과 | 사용자 요청 시 `<Combobox>` | 검색 필요 시 |
|
||||||
|
| 테이블/데이터베이스 선택 | `<Combobox>` | 많은 항목 + 검색 필수 |
|
||||||
|
| 간단한 상태 선택 | `<Select>` | 빠른 선택 |
|
||||||
|
|
||||||
|
**원칙:**
|
||||||
|
- 사용자가 명시적으로 요청하지 않으면 일반 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
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
8자 이상 입력해주세요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
<p className="text-xs text-destructive mt-1.5 flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
이메일 형식이 올바르지 않습니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
<p className="text-xs text-success mt-1.5 flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
사용 가능한 이메일입니다
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Label (폼 라벨):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 기본 라벨
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
|
||||||
|
// 필수 항목 표시
|
||||||
|
<label className="text-sm font-medium leading-none">
|
||||||
|
이메일 <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
**전체 폼 필드 구조:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
이메일 <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border px-3 py-2 text-sm",
|
||||||
|
error ? "border-destructive" : "border-input",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2",
|
||||||
|
error ? "focus-visible:ring-destructive" : "focus-visible:ring-ring"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!error && helperText && (
|
||||||
|
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**실시간 검증 피드백:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 로딩 중 (검증 진행)
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className="..." />
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 성공
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className="border-success ..." />
|
||||||
|
<CheckCircle className="h-4 w-4 text-success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 실패
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className="border-destructive ..." />
|
||||||
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 20. Loading States (로딩 상태)
|
||||||
|
|
||||||
|
**Spinner (스피너) 크기별:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Small
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|
||||||
|
// Default
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
|
||||||
|
// Large
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spinner 색상별:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Primary
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
|
||||||
|
// Muted
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
|
||||||
|
// White (다크 배경용)
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary-foreground" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Button Loading:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4"
|
||||||
|
>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
처리 중...
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skeleton UI:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 텍스트 스켈레톤
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-full bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-1/2 bg-muted rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 카드 스켈레톤
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-12 w-12 bg-muted rounded-full animate-pulse" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<div className="h-4 w-1/3 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 w-full bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-3 w-full bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Progress Bar (진행률):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 기본 Progress Bar
|
||||||
|
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 라벨 포함
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">업로드 중...</span>
|
||||||
|
<span className="font-medium">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Page Loading:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 21. Empty States (빈 상태)
|
||||||
|
|
||||||
|
**기본 Empty State:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Inbox className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">데이터가 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||||
|
아직 생성된 항목이 없습니다. 새로운 항목을 추가해보세요.
|
||||||
|
</p>
|
||||||
|
<button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 항목 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**검색 결과 없음:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">검색 결과가 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||||
|
"{searchQuery}"에 대한 결과를 찾을 수 없습니다. 다른 검색어로 시도해보세요.
|
||||||
|
</p>
|
||||||
|
<button className="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-4">
|
||||||
|
검색어 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러 상태:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">데이터를 불러올 수 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||||
|
일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
|
||||||
|
</p>
|
||||||
|
<button className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground h-10 px-4">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**아이콘 가이드:**
|
||||||
|
- 데이터 없음: Inbox, Package, FileText
|
||||||
|
- 검색 결과 없음: Search, SearchX
|
||||||
|
- 필터 결과 없음: Filter, FilterX
|
||||||
|
- 에러: AlertCircle, XCircle
|
||||||
|
- 네트워크 오류: WifiOff, CloudOff
|
||||||
|
- 권한 없음: Lock, ShieldOff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 추가 프로젝트 규칙
|
||||||
|
|
||||||
|
- 백엔드 재실행 금지
|
||||||
|
- 항상 한글로 답변
|
||||||
|
- 이모지 사용 금지 (명시적 요청 없이)
|
||||||
|
- 심플하고 깔끔한 디자인 유지
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
**상태**: 완료 ✅
|
||||||
|
|
@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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<ExternalRestApiConnection>
|
||||||
|
);
|
||||||
|
static async deleteConnection(id: number);
|
||||||
|
|
||||||
|
// 테스트 메서드
|
||||||
|
static async testConnection(
|
||||||
|
testRequest: RestApiTestRequest
|
||||||
|
): Promise<RestApiTestResult>;
|
||||||
|
static async testConnectionById(
|
||||||
|
id: number,
|
||||||
|
endpoint?: string
|
||||||
|
): Promise<RestApiTestResult>;
|
||||||
|
|
||||||
|
// 헬퍼 메서드
|
||||||
|
private static buildHeaders(
|
||||||
|
connection: ExternalRestApiConnection
|
||||||
|
): Record<string, string>;
|
||||||
|
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<RestApiTestResult> {
|
||||||
|
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<ConnectionTabType>("database");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 메인 페이지 구조 개선
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 탭 헤더
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as ConnectionTabType)}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-[400px] grid-cols-2">
|
||||||
|
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
데이터베이스 연결
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
REST API 연결
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 데이터베이스 연결 탭 */}
|
||||||
|
<TabsContent value="database">
|
||||||
|
<DatabaseConnectionList />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* REST API 연결 탭 */}
|
||||||
|
<TabsContent value="rest-api">
|
||||||
|
<RestApiConnectionList />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. REST API 연결 목록 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/admin/RestApiConnectionList.tsx
|
||||||
|
|
||||||
|
export function RestApiConnectionList() {
|
||||||
|
const [connections, setConnections] = useState<ExternalRestApiConnection[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
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<string, string>;
|
||||||
|
onChange: (headers: Record<string, string>) => 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<string, string>);
|
||||||
|
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<ExternalRestApiConnection>
|
||||||
|
) {
|
||||||
|
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<RestApiTestResult> {
|
||||||
|
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<RestApiTestResult> {
|
||||||
|
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
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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 <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\">\r\n <p><strong>---------- 전달된 메시지 ----------</strong></p>\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": [],
|
||||||
|
"deletedAt": "2025-10-22T06:36:10.876Z"
|
||||||
|
}
|
||||||
|
|
@ -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보낸사람: \"이희진\" <zian9227@naver.com>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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 <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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": [
|
||||||
|
"\"이희진\" <zian9227@naver.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: ㅏㅣ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<f085efa6-2668-0293-57de-88b1e7009dd1@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": [],
|
||||||
|
"deletedAt": "2025-10-22T07:11:04.666Z"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\"></p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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": [
|
||||||
|
"\"이희진\" <zian9227@naver.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: ㅅㄷㄴㅅ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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": [
|
||||||
|
"\"이희진\" <zian9227@naver.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: ㅅㄷㄴㅅ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": [],
|
||||||
|
"deletedAt": "2025-10-22T07:11:10.245Z"
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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 <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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 <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 13. 오전 10:40:30</p>\n <p><strong>제목:</strong> 수신메일확인용</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\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"
|
||||||
|
}
|
||||||
|
|
@ -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보낸사람: \"이희진\" <zian9227@naver.com>\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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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": [
|
||||||
|
"\"권은아\" <chna8137s@gmail.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: 매우 졸린 오후예요",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\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": []
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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\" <ddhhss0603@gmail.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: 안녕하세여",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"ddhhss0603@gmail.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -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": [
|
||||||
|
"\"이희진\" <zian9227@naver.com>"
|
||||||
|
],
|
||||||
|
"subject": "Re: ㅅㄷㄴㅅ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\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": "<e68a0501-f79a-8713-a625-e882f711b30d@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": [],
|
||||||
|
"deletedAt": "2025-10-22T07:11:12.907Z"
|
||||||
|
}
|
||||||
|
|
@ -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",
|
"id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05",
|
||||||
"title": "연동되어주려무니",
|
"title": "연동되어주려무니",
|
||||||
"description": "ㅁㄴㅇㄹ",
|
"description": "ㅁㄴㅇㄹ",
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"assignedTo": "",
|
"assignedTo": "",
|
||||||
"dueDate": "2025-10-21T15:21",
|
"dueDate": "2025-10-21T15:21",
|
||||||
"createdAt": "2025-10-20T06:21:19.817Z",
|
"createdAt": "2025-10-20T06:21:19.817Z",
|
||||||
"updatedAt": "2025-10-20T06:21:19.817Z",
|
"updatedAt": "2025-10-20T09:00:26.948Z",
|
||||||
"isUrgent": false,
|
"isUrgent": false,
|
||||||
"order": 3
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -31,6 +31,8 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
|
|
@ -3433,6 +3435,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
|
@ -4437,6 +4454,24 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
|
@ -4610,6 +4645,15 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -4988,6 +5052,23 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/define-lazy-prop": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
|
@ -5554,6 +5652,12 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
|
@ -5689,6 +5793,12 @@
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -5696,6 +5806,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -5997,6 +6113,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
|
@ -6249,6 +6374,18 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
|
@ -6563,6 +6700,22 @@
|
||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
|
|
@ -6599,6 +6752,22 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-docker": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||||
|
|
@ -6701,6 +6870,24 @@
|
||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
|
@ -7658,6 +7845,24 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
|
@ -7670,6 +7875,13 @@
|
||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
|
@ -8292,6 +8504,31 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|
@ -8436,6 +8673,12 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -8960,6 +9203,35 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
|
@ -9003,6 +9275,67 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
|
@ -9054,6 +9387,26 @@
|
||||||
"@redis/time-series": "1.1.0"
|
"@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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -9325,6 +9678,38 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,12 @@ import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||||
|
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
|
import externalRestApiConnectionRoutes from "./routes/externalRestApiConnectionRoutes";
|
||||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||||
import screenFileRoutes from "./routes/screenFileRoutes";
|
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||||
|
|
@ -185,11 +187,13 @@ app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||||
|
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
|
||||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
|
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||||
app.use("/api/screen-files", screenFileRoutes);
|
app.use("/api/screen-files", screenFileRoutes);
|
||||||
app.use("/api/batch-configs", batchRoutes);
|
app.use("/api/batch-configs", batchRoutes);
|
||||||
|
|
@ -268,6 +272,28 @@ app.listen(PORT, HOST, async () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, 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;
|
export default app;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export class DashboardController {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -89,7 +91,8 @@ export class DashboardController {
|
||||||
|
|
||||||
const savedDashboard = await DashboardService.createDashboard(
|
const savedDashboard = await DashboardService.createDashboard(
|
||||||
dashboardData,
|
dashboardData,
|
||||||
userId
|
userId,
|
||||||
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||||
|
|
@ -121,6 +124,7 @@ export class DashboardController {
|
||||||
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
const query: DashboardListQuery = {
|
const query: DashboardListQuery = {
|
||||||
page: parseInt(req.query.page as string) || 1,
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
|
@ -145,7 +149,11 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(query, userId);
|
const result = await DashboardService.getDashboards(
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -173,6 +181,7 @@ export class DashboardController {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -182,7 +191,11 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = await DashboardService.getDashboardById(id, userId);
|
const dashboard = await DashboardService.getDashboardById(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -393,6 +406,8 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
const query: DashboardListQuery = {
|
const query: DashboardListQuery = {
|
||||||
page: parseInt(req.query.page as string) || 1,
|
page: parseInt(req.query.page as string) || 1,
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
|
@ -401,7 +416,11 @@ export class DashboardController {
|
||||||
createdBy: userId, // 본인이 만든 대시보드만
|
createdBy: userId, // 본인이 만든 대시보드만
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(query, userId);
|
const result = await DashboardService.getDashboards(
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -422,7 +441,7 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 쿼리 실행
|
* 쿼리 실행 (SELECT만)
|
||||||
* POST /api/dashboards/execute-query
|
* POST /api/dashboards/execute-query
|
||||||
*/
|
*/
|
||||||
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
|
@ -487,6 +506,79 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DML 쿼리 실행 (INSERT, UPDATE, DELETE)
|
||||||
|
* POST /api/dashboards/execute-dml
|
||||||
|
*/
|
||||||
|
async executeDML(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
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 우회용)
|
* 외부 API 프록시 (CORS 우회용)
|
||||||
* POST /api/dashboards/fetch-external-api
|
* POST /api/dashboards/fetch-external-api
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,18 @@ export const saveFormData = async (
|
||||||
formDataWithMeta.company_code = companyCode;
|
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(
|
const result = await dynamicFormService.saveFormData(
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
formDataWithMeta
|
formDataWithMeta,
|
||||||
|
ipAddress
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,28 @@ export class FlowController {
|
||||||
*/
|
*/
|
||||||
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { name, description, tableName } = req.body;
|
const { name, description, tableName, dbSourceType, dbConnectionId } =
|
||||||
|
req.body;
|
||||||
const userId = (req as any).user?.userId || "system";
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Name and tableName are required",
|
message: "Name is required",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 존재 확인
|
// 테이블 이름이 제공된 경우에만 존재 확인
|
||||||
|
if (tableName) {
|
||||||
const tableExists =
|
const tableExists =
|
||||||
await this.flowDefinitionService.checkTableExists(tableName);
|
await this.flowDefinitionService.checkTableExists(tableName);
|
||||||
if (!tableExists) {
|
if (!tableExists) {
|
||||||
|
|
@ -52,9 +62,10 @@ export class FlowController {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const flowDef = await this.flowDefinitionService.create(
|
const flowDef = await this.flowDefinitionService.create(
|
||||||
{ name, description, tableName },
|
{ name, description, tableName, dbSourceType, dbConnectionId },
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -294,6 +305,13 @@ export class FlowController {
|
||||||
color,
|
color,
|
||||||
positionX,
|
positionX,
|
||||||
positionY,
|
positionY,
|
||||||
|
moveType,
|
||||||
|
statusColumn,
|
||||||
|
statusValue,
|
||||||
|
targetTable,
|
||||||
|
fieldMappings,
|
||||||
|
integrationType,
|
||||||
|
integrationConfig,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const step = await this.flowStepService.update(id, {
|
const step = await this.flowStepService.update(id, {
|
||||||
|
|
@ -304,6 +322,13 @@ export class FlowController {
|
||||||
color,
|
color,
|
||||||
positionX,
|
positionX,
|
||||||
positionY,
|
positionY,
|
||||||
|
moveType,
|
||||||
|
statusColumn,
|
||||||
|
statusValue,
|
||||||
|
targetTable,
|
||||||
|
fieldMappings,
|
||||||
|
integrationType,
|
||||||
|
integrationConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!step) {
|
if (!step) {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async getMailList(req: Request, res: Response) {
|
async getMailList(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
console.log('📬 메일 목록 조회 요청:', {
|
// console.log('📬 메일 목록 조회 요청:', {
|
||||||
params: req.params,
|
// params: req.params,
|
||||||
path: req.path,
|
// path: req.path,
|
||||||
originalUrl: req.originalUrl
|
// originalUrl: req.originalUrl
|
||||||
});
|
// });
|
||||||
|
|
||||||
const { accountId } = req.params;
|
const { accountId } = req.params;
|
||||||
const limit = parseInt(req.query.limit as string) || 50;
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
@ -49,11 +49,11 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async getMailDetail(req: Request, res: Response) {
|
async getMailDetail(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 메일 상세 조회 요청:', {
|
// console.log('🔍 메일 상세 조회 요청:', {
|
||||||
params: req.params,
|
// params: req.params,
|
||||||
path: req.path,
|
// path: req.path,
|
||||||
originalUrl: req.originalUrl
|
// originalUrl: req.originalUrl
|
||||||
});
|
// });
|
||||||
|
|
||||||
const { accountId, seqno } = req.params;
|
const { accountId, seqno } = req.params;
|
||||||
const seqnoNumber = parseInt(seqno, 10);
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
|
|
@ -121,39 +121,39 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async downloadAttachment(req: Request, res: Response) {
|
async downloadAttachment(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
// console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||||
const { accountId, seqno, index } = req.params;
|
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 seqnoNumber = parseInt(seqno, 10);
|
||||||
const indexNumber = parseInt(index, 10);
|
const indexNumber = parseInt(index, 10);
|
||||||
|
|
||||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||||
console.log('❌ 유효하지 않은 파라미터');
|
// console.log('❌ 유효하지 않은 파라미터');
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '유효하지 않은 파라미터입니다.',
|
message: '유효하지 않은 파라미터입니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📎 서비스 호출 시작...');
|
// console.log('📎 서비스 호출 시작...');
|
||||||
const result = await this.mailReceiveService.downloadAttachment(
|
const result = await this.mailReceiveService.downloadAttachment(
|
||||||
accountId,
|
accountId,
|
||||||
seqnoNumber,
|
seqnoNumber,
|
||||||
indexNumber
|
indexNumber
|
||||||
);
|
);
|
||||||
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
// console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.log('❌ 첨부파일을 찾을 수 없음');
|
// console.log('❌ 첨부파일을 찾을 수 없음');
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '첨부파일을 찾을 수 없습니다.',
|
message: '첨부파일을 찾을 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
// console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||||
console.log(`📎 파일 경로: ${result.filePath}`);
|
// console.log(`📎 파일 경로: ${result.filePath}`);
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드
|
||||||
res.download(result.filePath, result.filename, (err) => {
|
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();
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ export class MailSendSimpleController {
|
||||||
*/
|
*/
|
||||||
async sendMail(req: Request, res: Response) {
|
async sendMail(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
console.log('📧 메일 발송 요청 수신:', {
|
// console.log('📧 메일 발송 요청 수신:', {
|
||||||
accountId: req.body.accountId,
|
// accountId: req.body.accountId,
|
||||||
to: req.body.to,
|
// to: req.body.to,
|
||||||
cc: req.body.cc,
|
// cc: req.body.cc,
|
||||||
bcc: req.body.bcc,
|
// bcc: req.body.bcc,
|
||||||
subject: req.body.subject,
|
// subject: req.body.subject,
|
||||||
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
// attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// FormData에서 JSON 문자열 파싱
|
// FormData에서 JSON 문자열 파싱
|
||||||
const accountId = req.body.accountId;
|
const accountId = req.body.accountId;
|
||||||
|
|
@ -31,7 +31,7 @@ export class MailSendSimpleController {
|
||||||
|
|
||||||
// 필수 파라미터 검증
|
// 필수 파라미터 검증
|
||||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||||
console.log('❌ 필수 파라미터 누락');
|
// console.log('❌ 필수 파라미터 누락');
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||||
|
|
@ -63,9 +63,9 @@ export class MailSendSimpleController {
|
||||||
if (req.body.fileNames) {
|
if (req.body.fileNames) {
|
||||||
try {
|
try {
|
||||||
parsedFileNames = JSON.parse(req.body.fileNames);
|
parsedFileNames = JSON.parse(req.body.fileNames);
|
||||||
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
// console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('파일명 파싱 실패, multer originalname 사용');
|
// console.warn('파일명 파싱 실패, multer originalname 사용');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,10 +83,10 @@ export class MailSendSimpleController {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
// console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
||||||
filename: a.filename,
|
// filename: a.filename,
|
||||||
path: a.path.split('/').pop()
|
// 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 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ export class MailSentHistoryController {
|
||||||
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
||||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||||
searchTerm: req.query.searchTerm 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,
|
accountId: req.query.accountId as string | undefined,
|
||||||
startDate: req.query.startDate as string | undefined,
|
startDate: req.query.startDate as string | undefined,
|
||||||
endDate: req.query.endDate 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,
|
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
||||||
|
includeDeleted: req.query.includeDeleted === 'true',
|
||||||
|
onlyDeleted: req.query.onlyDeleted === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await mailSentHistoryService.getSentMailList(query);
|
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();
|
export const mailSentHistoryController = new MailSentHistoryController();
|
||||||
|
|
|
||||||
|
|
@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe
|
||||||
clouds = 30;
|
clouds = 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 격자좌표 → 위도경도 변환
|
||||||
|
const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
city: gridCoord.name,
|
city: gridCoord.name,
|
||||||
country: 'KR',
|
country: 'KR',
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
temperature: Math.round(temperature),
|
temperature: Math.round(temperature),
|
||||||
feelsLike: Math.round(temperature - 2),
|
feelsLike: Math.round(temperature - 2),
|
||||||
humidity: Math.round(humidity),
|
humidity: Math.round(humidity),
|
||||||
|
|
@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe
|
||||||
return grids[city] || null;
|
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 응답 데이터
|
* @param apiResponse - 공공데이터포털 API 응답 데이터
|
||||||
|
|
@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string;
|
||||||
weatherDescription = '추움';
|
weatherDescription = '추움';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 격자좌표 → 위도경도 변환
|
||||||
|
const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
city: gridInfo.name,
|
city: gridInfo.name,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
temperature: Math.round(temperature * 10) / 10,
|
temperature: Math.round(temperature * 10) / 10,
|
||||||
humidity: Math.round(humidity),
|
humidity: Math.round(humidity),
|
||||||
windSpeed: Math.round(windSpeed * 10) / 10,
|
windSpeed: Math.round(windSpeed * 10) / 10,
|
||||||
|
|
|
||||||
|
|
@ -1048,3 +1048,268 @@ export async function updateColumnWebType(
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 🎯 테이블 로그 시스템 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
export async function createLogTable(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { pkColumn } = req.body;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<null> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 테이블이 성공적으로 생성되었습니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 테이블 생성 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 설정 조회: ${tableName} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<typeof logConfig> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 설정을 조회했습니다.",
|
||||||
|
data: logConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 설정 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<void> {
|
||||||
|
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<null> = {
|
||||||
|
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<typeof result> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 데이터를 조회했습니다.",
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 데이터 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { isActive } = req.body;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive === undefined || isActive === null) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
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<null> = {
|
||||||
|
success: true,
|
||||||
|
message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 테이블 토글 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "로그 테이블 토글 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "LOG_TOGGLE_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,18 @@ router.get(
|
||||||
dashboardController.getDashboard.bind(dashboardController)
|
dashboardController.getDashboard.bind(dashboardController)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 쿼리 실행 (인증 불필요 - 개발용)
|
// 쿼리 실행 (SELECT만, 인증 불필요 - 개발용)
|
||||||
router.post(
|
router.post(
|
||||||
"/execute-query",
|
"/execute-query",
|
||||||
dashboardController.executeQuery.bind(dashboardController)
|
dashboardController.executeQuery.bind(dashboardController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DML 쿼리 실행 (INSERT/UPDATE/DELETE, 인증 불필요 - 개발용)
|
||||||
|
router.post(
|
||||||
|
"/execute-dml",
|
||||||
|
dashboardController.executeDML.bind(dashboardController)
|
||||||
|
);
|
||||||
|
|
||||||
// 외부 API 프록시 (CORS 우회)
|
// 외부 API 프록시 (CORS 우회)
|
||||||
router.post(
|
router.post(
|
||||||
"/fetch-external-api",
|
"/fetch-external-api",
|
||||||
|
|
|
||||||
|
|
@ -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<ExternalRestApiConnection> = {
|
||||||
|
...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;
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { FlowController } from "../controllers/flowController";
|
import { FlowController } from "../controllers/flowController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const flowController = new FlowController();
|
const flowController = new FlowController();
|
||||||
|
|
@ -32,8 +33,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList);
|
||||||
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||||
|
|
||||||
// ==================== 데이터 이동 ====================
|
// ==================== 데이터 이동 ====================
|
||||||
router.post("/move", flowController.moveData);
|
router.post("/move", authenticateToken, flowController.moveData);
|
||||||
router.post("/move-batch", flowController.moveBatchData);
|
router.post("/move-batch", authenticateToken, flowController.moveBatchData);
|
||||||
|
|
||||||
// ==================== 오딧 로그 ====================
|
// ==================== 오딧 로그 ====================
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
|
|
|
||||||
|
|
@ -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.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||||
|
|
||||||
|
// 메일 삭제 - 구체적인 경로
|
||||||
|
router.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res));
|
||||||
|
|
||||||
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
||||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ router.post(
|
||||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
(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 연결 테스트
|
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,37 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의)
|
||||||
|
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
||||||
|
|
||||||
// GET /api/mail/sent - 발송 이력 목록 조회
|
// GET /api/mail/sent - 발송 이력 목록 조회
|
||||||
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
||||||
|
|
||||||
// GET /api/mail/sent/statistics - 통계 조회
|
// POST /api/mail/sent/draft - 임시 저장 (Draft)
|
||||||
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
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 - 특정 발송 이력 상세 조회
|
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
|
||||||
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
|
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));
|
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import {
|
||||||
checkTableExists,
|
checkTableExists,
|
||||||
getColumnWebTypes,
|
getColumnWebTypes,
|
||||||
checkDatabaseConnection,
|
checkDatabaseConnection,
|
||||||
|
createLogTable,
|
||||||
|
getLogConfig,
|
||||||
|
getLogData,
|
||||||
|
toggleLogTable,
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData);
|
||||||
*/
|
*/
|
||||||
router.delete("/tables/:tableName/delete", deleteTableData);
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ export class DashboardService {
|
||||||
*/
|
*/
|
||||||
static async createDashboard(
|
static async createDashboard(
|
||||||
data: CreateDashboardRequest,
|
data: CreateDashboardRequest,
|
||||||
userId: string
|
userId: string,
|
||||||
|
companyCode?: string
|
||||||
): Promise<Dashboard> {
|
): Promise<Dashboard> {
|
||||||
const dashboardId = uuidv4();
|
const dashboardId = uuidv4();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -31,8 +32,8 @@ export class DashboardService {
|
||||||
`
|
`
|
||||||
INSERT INTO dashboards (
|
INSERT INTO dashboards (
|
||||||
id, title, description, is_public, created_by,
|
id, title, description, is_public, created_by,
|
||||||
created_at, updated_at, tags, category, view_count, settings
|
created_at, updated_at, tags, category, view_count, settings, company_code
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
dashboardId,
|
dashboardId,
|
||||||
|
|
@ -46,6 +47,7 @@ export class DashboardService {
|
||||||
data.category || null,
|
data.category || null,
|
||||||
0,
|
0,
|
||||||
JSON.stringify(data.settings || {}),
|
JSON.stringify(data.settings || {}),
|
||||||
|
companyCode || "DEFAULT",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -61,9 +63,9 @@ export class DashboardService {
|
||||||
id, dashboard_id, element_type, element_subtype,
|
id, dashboard_id, element_type, element_subtype,
|
||||||
position_x, position_y, width, height,
|
position_x, position_y, width, height,
|
||||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
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
|
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,
|
elementId,
|
||||||
|
|
@ -82,6 +84,7 @@ export class DashboardService {
|
||||||
JSON.stringify(element.chartConfig || {}),
|
JSON.stringify(element.chartConfig || {}),
|
||||||
JSON.stringify(element.listConfig || null),
|
JSON.stringify(element.listConfig || null),
|
||||||
JSON.stringify(element.yardConfig || null),
|
JSON.stringify(element.yardConfig || null),
|
||||||
|
JSON.stringify(element.customMetricConfig || null),
|
||||||
i,
|
i,
|
||||||
now,
|
now,
|
||||||
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 {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
|
|
@ -161,6 +168,13 @@ export class DashboardService {
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 코드 필터링 (최우선)
|
||||||
|
if (companyCode) {
|
||||||
|
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
// 권한 필터링
|
// 권한 필터링
|
||||||
if (userId) {
|
if (userId) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
|
|
@ -278,7 +292,8 @@ export class DashboardService {
|
||||||
*/
|
*/
|
||||||
static async getDashboardById(
|
static async getDashboardById(
|
||||||
dashboardId: string,
|
dashboardId: string,
|
||||||
userId?: string
|
userId?: string,
|
||||||
|
companyCode?: string
|
||||||
): Promise<Dashboard | null> {
|
): Promise<Dashboard | null> {
|
||||||
try {
|
try {
|
||||||
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
|
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
|
||||||
|
|
@ -286,6 +301,16 @@ export class DashboardService {
|
||||||
let dashboardParams: any[];
|
let dashboardParams: any[];
|
||||||
|
|
||||||
if (userId) {
|
if (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 = `
|
dashboardQuery = `
|
||||||
SELECT d.*
|
SELECT d.*
|
||||||
FROM dashboards d
|
FROM dashboards d
|
||||||
|
|
@ -293,6 +318,17 @@ export class DashboardService {
|
||||||
AND (d.created_by = $2 OR d.is_public = true)
|
AND (d.created_by = $2 OR d.is_public = true)
|
||||||
`;
|
`;
|
||||||
dashboardParams = [dashboardId, userId];
|
dashboardParams = [dashboardId, userId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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 {
|
} else {
|
||||||
dashboardQuery = `
|
dashboardQuery = `
|
||||||
SELECT d.*
|
SELECT d.*
|
||||||
|
|
@ -302,6 +338,7 @@ export class DashboardService {
|
||||||
`;
|
`;
|
||||||
dashboardParams = [dashboardId];
|
dashboardParams = [dashboardId];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dashboardResult = await PostgreSQLService.query(
|
const dashboardResult = await PostgreSQLService.query(
|
||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
|
|
@ -355,6 +392,11 @@ export class DashboardService {
|
||||||
? JSON.parse(row.yard_config)
|
? JSON.parse(row.yard_config)
|
||||||
: row.yard_config
|
: row.yard_config
|
||||||
: undefined,
|
: 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,
|
id, dashboard_id, element_type, element_subtype,
|
||||||
position_x, position_y, width, height,
|
position_x, position_y, width, height,
|
||||||
title, custom_title, show_header, content, data_source_config, chart_config,
|
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
|
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,
|
elementId,
|
||||||
|
|
@ -499,6 +541,7 @@ export class DashboardService {
|
||||||
JSON.stringify(element.chartConfig || {}),
|
JSON.stringify(element.chartConfig || {}),
|
||||||
JSON.stringify(element.listConfig || null),
|
JSON.stringify(element.listConfig || null),
|
||||||
JSON.stringify(element.yardConfig || null),
|
JSON.stringify(element.yardConfig || null),
|
||||||
|
JSON.stringify(element.customMetricConfig || null),
|
||||||
i,
|
i,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|
|
||||||
|
|
@ -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<string, any>
|
||||||
|
): { 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
|
|
||||||
|
|
@ -203,7 +203,8 @@ export class DynamicFormService {
|
||||||
async saveFormData(
|
async saveFormData(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: Record<string, any>
|
data: Record<string, any>,
|
||||||
|
ipAddress?: string
|
||||||
): Promise<FormDataResult> {
|
): Promise<FormDataResult> {
|
||||||
try {
|
try {
|
||||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||||
|
|
@ -432,7 +433,19 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
||||||
console.log("📊 SQL 파라미터:", values);
|
console.log("📊 SQL 파라미터:", values);
|
||||||
|
|
||||||
const result = await query<any>(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);
|
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<number, any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 연결 정보 조회
|
||||||
|
*/
|
||||||
|
async function getExternalConnection(
|
||||||
|
connectionId: number
|
||||||
|
): Promise<ExternalDbConnection | null> {
|
||||||
|
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<any> {
|
||||||
|
// 캐시된 연결 풀 확인
|
||||||
|
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<PgPool> {
|
||||||
|
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<mysql.Pool> {
|
||||||
|
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<any> {
|
||||||
|
// 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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<void> {
|
||||||
|
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<any> {
|
||||||
|
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<any>
|
||||||
|
): Promise<any> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ApiResponse<ExternalRestApiConnection[]>> {
|
||||||
|
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<any> = 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<ApiResponse<ExternalRestApiConnection>> {
|
||||||
|
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<any> = 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<ApiResponse<ExternalRestApiConnection>> {
|
||||||
|
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<any> = 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<ExternalRestApiConnection>
|
||||||
|
): Promise<ApiResponse<ExternalRestApiConnection>> {
|
||||||
|
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<any> = 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<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
DELETE FROM external_rest_api_connections
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING connection_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result: QueryResult<any> = 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<RestApiTestResult> {
|
||||||
|
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<RestApiTestResult> {
|
||||||
|
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("올바르지 않은 인증 타입입니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,25 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import db from "../database/db";
|
import db from "../database/db";
|
||||||
import { FlowAuditLog, FlowIntegrationContext } from "../types/flow";
|
import {
|
||||||
|
FlowAuditLog,
|
||||||
|
FlowIntegrationContext,
|
||||||
|
FlowDefinition,
|
||||||
|
} from "../types/flow";
|
||||||
import { FlowDefinitionService } from "./flowDefinitionService";
|
import { FlowDefinitionService } from "./flowDefinitionService";
|
||||||
import { FlowStepService } from "./flowStepService";
|
import { FlowStepService } from "./flowStepService";
|
||||||
import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService";
|
import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService";
|
||||||
|
import {
|
||||||
|
getExternalPool,
|
||||||
|
executeExternalQuery,
|
||||||
|
executeExternalTransaction,
|
||||||
|
} from "./externalDbHelper";
|
||||||
|
import {
|
||||||
|
getPlaceholder,
|
||||||
|
buildUpdateQuery,
|
||||||
|
buildInsertQuery,
|
||||||
|
buildSelectQuery,
|
||||||
|
} from "./dbQueryBuilder";
|
||||||
|
|
||||||
export class FlowDataMoveService {
|
export class FlowDataMoveService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -33,6 +48,28 @@ export class FlowDataMoveService {
|
||||||
userId: string = "system",
|
userId: string = "system",
|
||||||
additionalData?: Record<string, any>
|
additionalData?: Record<string, any>
|
||||||
): Promise<{ success: boolean; targetDataId?: any; message?: string }> {
|
): 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) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
|
|
@ -124,6 +161,28 @@ export class FlowDataMoveService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 감사 로그 기록
|
// 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, {
|
await this.logDataMove(client, {
|
||||||
flowId,
|
flowId,
|
||||||
fromStepId,
|
fromStepId,
|
||||||
|
|
@ -136,6 +195,11 @@ export class FlowDataMoveService {
|
||||||
statusFrom: fromStep.statusValue,
|
statusFrom: fromStep.statusValue,
|
||||||
statusTo: toStep.statusValue,
|
statusTo: toStep.statusValue,
|
||||||
userId,
|
userId,
|
||||||
|
dbConnectionId:
|
||||||
|
flowDefinition.dbSourceType === "external"
|
||||||
|
? flowDefinition.dbConnectionId
|
||||||
|
: null,
|
||||||
|
dbConnectionName,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -160,7 +224,14 @@ export class FlowDataMoveService {
|
||||||
dataId: any,
|
dataId: any,
|
||||||
additionalData?: Record<string, any>
|
additionalData?: Record<string, any>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const statusColumn = toStep.statusColumn || "flow_status";
|
// 상태 컬럼이 지정되지 않은 경우 에러
|
||||||
|
if (!toStep.statusColumn) {
|
||||||
|
throw new Error(
|
||||||
|
`단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColumn = toStep.statusColumn;
|
||||||
const tableName = fromStep.tableName;
|
const tableName = fromStep.tableName;
|
||||||
|
|
||||||
// 추가 필드 업데이트 준비
|
// 추가 필드 업데이트 준비
|
||||||
|
|
@ -317,8 +388,9 @@ export class FlowDataMoveService {
|
||||||
move_type, source_table, target_table,
|
move_type, source_table, target_table,
|
||||||
source_data_id, target_data_id,
|
source_data_id, target_data_id,
|
||||||
status_from, status_to,
|
status_from, status_to,
|
||||||
changed_by, note
|
changed_by, note,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
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, [
|
await client.query(query, [
|
||||||
|
|
@ -334,6 +406,8 @@ export class FlowDataMoveService {
|
||||||
params.statusTo,
|
params.statusTo,
|
||||||
params.userId,
|
params.userId,
|
||||||
params.note || null,
|
params.note || null,
|
||||||
|
params.dbConnectionId || null,
|
||||||
|
params.dbConnectionName || null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,6 +482,8 @@ export class FlowDataMoveService {
|
||||||
targetDataId: row.target_data_id,
|
targetDataId: row.target_data_id,
|
||||||
statusFrom: row.status_from,
|
statusFrom: row.status_from,
|
||||||
statusTo: row.status_to,
|
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,
|
targetDataId: row.target_data_id,
|
||||||
statusFrom: row.status_from,
|
statusFrom: row.status_from,
|
||||||
statusTo: row.status_to,
|
statusTo: row.status_to,
|
||||||
|
dbConnectionId: row.db_connection_id,
|
||||||
|
dbConnectionName: row.db_connection_name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,4 +668,324 @@ export class FlowDataMoveService {
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 데이터 이동 처리
|
||||||
|
*/
|
||||||
|
private async moveDataToStepExternal(
|
||||||
|
dbConnectionId: number,
|
||||||
|
fromStepId: number,
|
||||||
|
toStepId: number,
|
||||||
|
dataId: any,
|
||||||
|
userId: string = "system",
|
||||||
|
additionalData?: Record<string, any>
|
||||||
|
): 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<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
// 상태 컬럼이 지정되지 않은 경우 에러
|
||||||
|
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<string, any>
|
||||||
|
): Promise<any> {
|
||||||
|
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<string, any> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,33 @@ export class FlowDefinitionService {
|
||||||
request: CreateFlowDefinitionRequest,
|
request: CreateFlowDefinitionRequest,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<FlowDefinition> {
|
): Promise<FlowDefinition> {
|
||||||
|
console.log("🔥 flowDefinitionService.create called with:", {
|
||||||
|
name: request.name,
|
||||||
|
description: request.description,
|
||||||
|
tableName: request.tableName,
|
||||||
|
dbSourceType: request.dbSourceType,
|
||||||
|
dbConnectionId: request.dbConnectionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO flow_definition (name, description, table_name, created_by)
|
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await db.query(query, [
|
const values = [
|
||||||
request.name,
|
request.name,
|
||||||
request.description || null,
|
request.description || null,
|
||||||
request.tableName,
|
request.tableName || null,
|
||||||
|
request.dbSourceType || "internal",
|
||||||
|
request.dbConnectionId || null,
|
||||||
userId,
|
userId,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
console.log("💾 Executing INSERT with values:", values);
|
||||||
|
|
||||||
|
const result = await db.query(query, values);
|
||||||
|
|
||||||
return this.mapToFlowDefinition(result[0]);
|
return this.mapToFlowDefinition(result[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +177,8 @@ export class FlowDefinitionService {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
tableName: row.table_name,
|
tableName: row.table_name,
|
||||||
|
dbSourceType: row.db_source_type || "internal",
|
||||||
|
dbConnectionId: row.db_connection_id,
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { FlowStepDataCount, FlowStepDataList } from "../types/flow";
|
||||||
import { FlowDefinitionService } from "./flowDefinitionService";
|
import { FlowDefinitionService } from "./flowDefinitionService";
|
||||||
import { FlowStepService } from "./flowStepService";
|
import { FlowStepService } from "./flowStepService";
|
||||||
import { FlowConditionParser } from "./flowConditionParser";
|
import { FlowConditionParser } from "./flowConditionParser";
|
||||||
|
import { executeExternalQuery } from "./externalDbHelper";
|
||||||
|
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
|
||||||
|
|
||||||
export class FlowExecutionService {
|
export class FlowExecutionService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -28,6 +30,13 @@ export class FlowExecutionService {
|
||||||
throw new Error(`Flow definition not found: ${flowId}`);
|
throw new Error(`Flow definition not found: ${flowId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("🔍 [getStepDataCount] Flow Definition:", {
|
||||||
|
flowId,
|
||||||
|
dbSourceType: flowDef.dbSourceType,
|
||||||
|
dbConnectionId: flowDef.dbConnectionId,
|
||||||
|
tableName: flowDef.tableName,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. 플로우 단계 조회
|
// 2. 플로우 단계 조회
|
||||||
const step = await this.flowStepService.findById(stepId);
|
const step = await this.flowStepService.findById(stepId);
|
||||||
if (!step) {
|
if (!step) {
|
||||||
|
|
@ -46,11 +55,40 @@ export class FlowExecutionService {
|
||||||
step.conditionJson
|
step.conditionJson
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. 카운트 쿼리 실행
|
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
||||||
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
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,12 +126,58 @@ export class FlowExecutionService {
|
||||||
|
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const isExternalDb =
|
||||||
|
flowDef.dbSourceType === "external" && flowDef.dbConnectionId;
|
||||||
|
|
||||||
// 5. 전체 카운트
|
// 5. 전체 카운트
|
||||||
const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
||||||
const countResult = await db.query(countQuery, params);
|
let countResult: any;
|
||||||
const total = parseInt(countResult[0].count);
|
let total: number;
|
||||||
|
|
||||||
// 6. 테이블의 Primary Key 컬럼 찾기
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 데이터 조회 (DB 타입별 페이징 처리)
|
||||||
|
let dataQuery: string;
|
||||||
|
let dataParams: any[];
|
||||||
|
|
||||||
|
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 = "";
|
let orderByColumn = "";
|
||||||
try {
|
try {
|
||||||
const pkQuery = `
|
const pkQuery = `
|
||||||
|
|
@ -109,19 +193,23 @@ export class FlowExecutionService {
|
||||||
orderByColumn = pkResult[0].attname;
|
orderByColumn = pkResult[0].attname;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Primary Key를 찾지 못하면 ORDER BY 없이 진행
|
|
||||||
console.warn(`Could not find primary key for table ${tableName}:`, err);
|
console.warn(`Could not find primary key for table ${tableName}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 데이터 조회
|
const orderByClause = orderByColumn
|
||||||
const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : "";
|
? `ORDER BY ${orderByColumn} DESC`
|
||||||
const dataQuery = `
|
: "";
|
||||||
|
dataQuery = `
|
||||||
SELECT * FROM ${tableName}
|
SELECT * FROM ${tableName}
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
${orderByClause}
|
${orderByClause}
|
||||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||||
`;
|
`;
|
||||||
const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);
|
const dataResult = await db.query(dataQuery, [
|
||||||
|
...params,
|
||||||
|
pageSize,
|
||||||
|
offset,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records: dataResult,
|
records: dataResult,
|
||||||
|
|
@ -130,6 +218,7 @@ export class FlowExecutionService {
|
||||||
pageSize,
|
pageSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우의 모든 단계별 데이터 카운트
|
* 플로우의 모든 단계별 데이터 카운트
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ export class MailReceiveBasicService {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
tls: config.tls,
|
tls: config.tls,
|
||||||
tlsOptions: { rejectUnauthorized: false },
|
tlsOptions: { rejectUnauthorized: false },
|
||||||
|
authTimeout: 30000, // 인증 타임아웃 30초
|
||||||
|
connTimeout: 30000, // 연결 타임아웃 30초
|
||||||
|
keepalive: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +119,7 @@ export class MailReceiveBasicService {
|
||||||
tls: true,
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
@ -130,7 +133,7 @@ export class MailReceiveBasicService {
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
imap.once("ready", () => {
|
imap.once("ready", () => {
|
||||||
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
// // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
imap.openBox("INBOX", true, (err: any, box: any) => {
|
imap.openBox("INBOX", true, (err: any, box: any) => {
|
||||||
|
|
@ -140,10 +143,10 @@ export class MailReceiveBasicService {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
// // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||||
const totalMessages = box.messages.total;
|
const totalMessages = box.messages.total;
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
// console.log('📭 메일함이 비어있습니다');
|
// // console.log('📭 메일함이 비어있습니다');
|
||||||
imap.end();
|
imap.end();
|
||||||
return resolve([]);
|
return resolve([]);
|
||||||
}
|
}
|
||||||
|
|
@ -152,19 +155,19 @@ export class MailReceiveBasicService {
|
||||||
const start = Math.max(1, totalMessages - limit + 1);
|
const start = Math.max(1, totalMessages - limit + 1);
|
||||||
const end = totalMessages;
|
const end = totalMessages;
|
||||||
|
|
||||||
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
// // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||||
bodies: ["HEADER", "TEXT"],
|
bodies: ["HEADER", "TEXT"],
|
||||||
struct: true,
|
struct: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(`📦 fetch 객체 생성 완료`);
|
// // console.log(`📦 fetch 객체 생성 완료`);
|
||||||
|
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
const totalToProcess = end - start + 1;
|
const totalToProcess = end - start + 1;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqno: any) => {
|
fetch.on("message", (msg: any, seqno: any) => {
|
||||||
// console.log(`📬 메일 #${seqno} 처리 시작`);
|
// // console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||||
let header: string = "";
|
let header: string = "";
|
||||||
let body: string = "";
|
let body: string = "";
|
||||||
let attributes: any = null;
|
let attributes: any = null;
|
||||||
|
|
@ -222,7 +225,7 @@ export class MailReceiveBasicService {
|
||||||
};
|
};
|
||||||
|
|
||||||
mails.push(mail);
|
mails.push(mail);
|
||||||
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
// // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||||
|
|
@ -240,18 +243,18 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
// // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||||
|
|
||||||
// 모든 메일 처리가 완료될 때까지 대기
|
// 모든 메일 처리가 완료될 때까지 대기
|
||||||
const checkComplete = setInterval(() => {
|
const checkComplete = setInterval(() => {
|
||||||
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
// // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||||
if (processedCount >= totalToProcess) {
|
if (processedCount >= totalToProcess) {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
// // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||||
imap.end();
|
imap.end();
|
||||||
// 최신 메일이 위로 오도록 정렬
|
// 최신 메일이 위로 오도록 정렬
|
||||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
// console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
// // console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||||
resolve(mails);
|
resolve(mails);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
@ -259,7 +262,7 @@ export class MailReceiveBasicService {
|
||||||
// 최대 10초 대기
|
// 최대 10초 대기
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
// // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||||
imap.end();
|
imap.end();
|
||||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
resolve(mails);
|
resolve(mails);
|
||||||
|
|
@ -275,10 +278,10 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("end", () => {
|
imap.once("end", () => {
|
||||||
// console.log('🔌 IMAP 연결 종료');
|
// // console.log('🔌 IMAP 연결 종료');
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('🔗 IMAP.connect() 호출...');
|
// // console.log('🔗 IMAP.connect() 호출...');
|
||||||
imap.connect();
|
imap.connect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -329,9 +332,9 @@ export class MailReceiveBasicService {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
// `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (seqno > box.messages.total || seqno < 1) {
|
if (seqno > box.messages.total || seqno < 1) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -350,21 +353,21 @@ export class MailReceiveBasicService {
|
||||||
let parsingComplete = false;
|
let parsingComplete = false;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqnum: any) => {
|
fetch.on("message", (msg: any, seqnum: any) => {
|
||||||
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
// console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||||
|
|
||||||
msg.on("body", (stream: any, info: any) => {
|
msg.on("body", (stream: any, info: any) => {
|
||||||
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
// console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
stream.on("data", (chunk: any) => {
|
stream.on("data", (chunk: any) => {
|
||||||
buffer += chunk.toString("utf8");
|
buffer += chunk.toString("utf8");
|
||||||
});
|
});
|
||||||
stream.once("end", async () => {
|
stream.once("end", async () => {
|
||||||
console.log(
|
// console.log(
|
||||||
`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
// `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||||
);
|
// );
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(buffer);
|
const parsed = await simpleParser(buffer);
|
||||||
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
// console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
||||||
|
|
||||||
const fromAddress = Array.isArray(parsed.from)
|
const fromAddress = Array.isArray(parsed.from)
|
||||||
? parsed.from[0]
|
? parsed.from[0]
|
||||||
|
|
@ -412,7 +415,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// msg 전체가 처리되었을 때 이벤트
|
// msg 전체가 처리되었을 때 이벤트
|
||||||
msg.once("end", () => {
|
msg.once("end", () => {
|
||||||
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
// console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -423,15 +426,15 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
// console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
||||||
|
|
||||||
// 비동기 파싱이 완료될 때까지 대기
|
// 비동기 파싱이 완료될 때까지 대기
|
||||||
const waitForParsing = setInterval(() => {
|
const waitForParsing = setInterval(() => {
|
||||||
if (parsingComplete) {
|
if (parsingComplete) {
|
||||||
clearInterval(waitForParsing);
|
clearInterval(waitForParsing);
|
||||||
console.log(
|
// console.log(
|
||||||
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
// `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
||||||
);
|
// );
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(mailDetail);
|
resolve(mailDetail);
|
||||||
}
|
}
|
||||||
|
|
@ -474,29 +477,47 @@ export class MailReceiveBasicService {
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
const accountAny = account as any;
|
const accountAny = account as any;
|
||||||
|
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
|
||||||
|
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
host: accountAny.imapHost || account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
port: imapPort,
|
||||||
tls: true,
|
tls: imapPort === 993, // 993 포트면 TLS 사용
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
||||||
|
// 타임아웃 설정
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.error('❌ IMAP 읽음 표시 타임아웃 (30초)');
|
||||||
|
imap.end();
|
||||||
|
reject(new Error("IMAP 연결 타임아웃"));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
imap.once("ready", () => {
|
imap.once("ready", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
// console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`);
|
||||||
|
|
||||||
|
// false로 변경: 쓰기 가능 모드로 INBOX 열기
|
||||||
imap.openBox("INBOX", false, (err: any, box: any) => {
|
imap.openBox("INBOX", false, (err: any, box: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
console.error('❌ INBOX 열기 실패:', err);
|
||||||
imap.end();
|
imap.end();
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log(`📬 INBOX 열림 (쓰기 가능 모드)`);
|
||||||
|
|
||||||
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
||||||
imap.end();
|
imap.end();
|
||||||
if (flagErr) {
|
if (flagErr) {
|
||||||
|
console.error("❌ 읽음 플래그 설정 실패:", flagErr);
|
||||||
reject(flagErr);
|
reject(flagErr);
|
||||||
} else {
|
} else {
|
||||||
|
// console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno);
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
message: "메일을 읽음으로 표시했습니다.",
|
message: "메일을 읽음으로 표시했습니다.",
|
||||||
|
|
@ -507,9 +528,16 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("error", (imapErr: any) => {
|
imap.once("error", (imapErr: any) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error('❌ IMAP 에러:', imapErr);
|
||||||
reject(imapErr);
|
reject(imapErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
imap.once("end", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`);
|
||||||
imap.connect();
|
imap.connect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -528,7 +556,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
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 accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
|
|
@ -538,7 +566,7 @@ export class MailReceiveBasicService {
|
||||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
@ -664,32 +692,32 @@ export class MailReceiveBasicService {
|
||||||
let parsingComplete = false;
|
let parsingComplete = false;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqnum: any) => {
|
fetch.on("message", (msg: any, seqnum: any) => {
|
||||||
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
// console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||||
|
|
||||||
msg.on("body", (stream: any, info: any) => {
|
msg.on("body", (stream: any, info: any) => {
|
||||||
console.log(`📎 메일 본문 스트림 시작`);
|
// console.log(`📎 메일 본문 스트림 시작`);
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
stream.on("data", (chunk: any) => {
|
stream.on("data", (chunk: any) => {
|
||||||
buffer += chunk.toString("utf8");
|
buffer += chunk.toString("utf8");
|
||||||
});
|
});
|
||||||
stream.once("end", async () => {
|
stream.once("end", async () => {
|
||||||
console.log(
|
// console.log(
|
||||||
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
// `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||||
);
|
// );
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(buffer);
|
const parsed = await simpleParser(buffer);
|
||||||
console.log(
|
// console.log(
|
||||||
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
// `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
if (
|
if (
|
||||||
parsed.attachments &&
|
parsed.attachments &&
|
||||||
parsed.attachments[attachmentIndex]
|
parsed.attachments[attachmentIndex]
|
||||||
) {
|
) {
|
||||||
const attachment = parsed.attachments[attachmentIndex];
|
const attachment = parsed.attachments[attachmentIndex];
|
||||||
console.log(
|
// console.log(
|
||||||
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
// `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
// 안전한 파일명 생성
|
// 안전한 파일명 생성
|
||||||
const safeFilename = this.sanitizeFilename(
|
const safeFilename = this.sanitizeFilename(
|
||||||
|
|
@ -701,7 +729,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// 파일 저장
|
// 파일 저장
|
||||||
await fs.writeFile(filePath, attachment.content);
|
await fs.writeFile(filePath, attachment.content);
|
||||||
console.log(`📎 파일 저장 완료: ${filePath}`);
|
// console.log(`📎 파일 저장 완료: ${filePath}`);
|
||||||
|
|
||||||
attachmentResult = {
|
attachmentResult = {
|
||||||
filePath,
|
filePath,
|
||||||
|
|
@ -711,9 +739,9 @@ export class MailReceiveBasicService {
|
||||||
};
|
};
|
||||||
parsingComplete = true;
|
parsingComplete = true;
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
// console.log(
|
||||||
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
// `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
||||||
);
|
// );
|
||||||
parsingComplete = true;
|
parsingComplete = true;
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|
@ -731,14 +759,14 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
// console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
||||||
|
|
||||||
// 파싱 완료를 기다림 (최대 5초)
|
// 파싱 완료를 기다림 (최대 5초)
|
||||||
const checkComplete = setInterval(() => {
|
const checkComplete = setInterval(() => {
|
||||||
if (parsingComplete) {
|
if (parsingComplete) {
|
||||||
console.log(
|
// console.log(
|
||||||
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
// `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||||
);
|
// );
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(attachmentResult);
|
resolve(attachmentResult);
|
||||||
|
|
@ -747,9 +775,9 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
console.log(
|
// console.log(
|
||||||
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
// `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||||
);
|
// );
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(attachmentResult);
|
resolve(attachmentResult);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
@ -774,4 +802,96 @@ export class MailReceiveBasicService {
|
||||||
.replace(/_{2,}/g, "_")
|
.replace(/_{2,}/g, "_")
|
||||||
.substring(0, 200); // 최대 길이 제한
|
.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();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,29 @@ export interface SendMailResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkSendRequest {
|
||||||
|
accountId: string;
|
||||||
|
templateId?: string; // 템플릿 ID (선택)
|
||||||
|
subject: string;
|
||||||
|
customHtml?: string; // 직접 작성한 HTML (선택)
|
||||||
|
recipients: Array<{
|
||||||
|
email: string;
|
||||||
|
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkSendResult {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{
|
||||||
|
email: string;
|
||||||
|
success: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
class MailSendSimpleService {
|
class MailSendSimpleService {
|
||||||
/**
|
/**
|
||||||
* 단일 메일 발송 또는 소규모 발송
|
* 단일 메일 발송 또는 소규모 발송
|
||||||
|
|
@ -63,7 +86,7 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
||||||
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
||||||
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
// console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
||||||
template.components = request.modifiedTemplateComponents;
|
template.components = request.modifiedTemplateComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,15 +107,15 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 4. 비밀번호 복호화
|
// 4. 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
// console.log('🔐 비밀번호 복호화 완료');
|
// // console.log('🔐 비밀번호 복호화 완료');
|
||||||
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
// // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
// // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 5. SMTP 연결 생성
|
// 5. SMTP 연결 생성
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
// console.log('📧 SMTP 연결 설정:', {
|
// // console.log('📧 SMTP 연결 설정:', {
|
||||||
// host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
// port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
// secure: isSecure,
|
// secure: isSecure,
|
||||||
|
|
@ -112,7 +135,7 @@ class MailSendSimpleService {
|
||||||
greetingTimeout: 30000,
|
greetingTimeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📧 메일 발송 시도 중...');
|
// console.log('📧 메일 발송 시도 중...');
|
||||||
|
|
||||||
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
||||||
const mailOptions: any = {
|
const mailOptions: any = {
|
||||||
|
|
@ -125,13 +148,13 @@ class MailSendSimpleService {
|
||||||
// 참조(CC) 추가
|
// 참조(CC) 추가
|
||||||
if (request.cc && request.cc.length > 0) {
|
if (request.cc && request.cc.length > 0) {
|
||||||
mailOptions.cc = request.cc.join(', ');
|
mailOptions.cc = request.cc.join(', ');
|
||||||
// console.log('📧 참조(CC):', request.cc);
|
// // console.log('📧 참조(CC):', request.cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숨은참조(BCC) 추가
|
// 숨은참조(BCC) 추가
|
||||||
if (request.bcc && request.bcc.length > 0) {
|
if (request.bcc && request.bcc.length > 0) {
|
||||||
mailOptions.bcc = request.bcc.join(', ');
|
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('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
||||||
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
// console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
console.log('✅ 메일 발송 성공:', {
|
// console.log('✅ 메일 발송 성공:', {
|
||||||
messageId: info.messageId,
|
// messageId: info.messageId,
|
||||||
accepted: info.accepted,
|
// accepted: info.accepted,
|
||||||
rejected: info.rejected,
|
// rejected: info.rejected,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 발송 이력 저장 (성공)
|
// 발송 이력 저장 (성공)
|
||||||
try {
|
try {
|
||||||
|
|
@ -402,6 +425,73 @@ class MailSendSimpleService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대량 메일 발송 (배치 처리)
|
||||||
|
*/
|
||||||
|
async sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
|
||||||
|
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 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
|
|
@ -414,13 +504,13 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
// console.log('🔐 테스트용 비밀번호 복호화 완료');
|
// // console.log('🔐 테스트용 비밀번호 복호화 완료');
|
||||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
// // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
// console.log('🧪 SMTP 연결 테스트 시작:', {
|
// // console.log('🧪 SMTP 연결 테스트 시작:', {
|
||||||
// host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
// port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
// secure: isSecure,
|
// secure: isSecure,
|
||||||
|
|
@ -443,7 +533,7 @@ class MailSendSimpleService {
|
||||||
// 연결 테스트
|
// 연결 테스트
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
|
|
||||||
console.log('✅ SMTP 연결 테스트 성공');
|
// console.log('✅ SMTP 연결 테스트 성공');
|
||||||
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class MailSentHistoryService {
|
||||||
mode: 0o644,
|
mode: 0o644,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("발송 이력 저장:", history.id);
|
// console.log("발송 이력 저장:", history.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("발송 이력 저장 실패:", error);
|
console.error("발송 이력 저장 실패:", error);
|
||||||
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
|
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
|
||||||
|
|
@ -86,7 +86,7 @@ class MailSentHistoryService {
|
||||||
try {
|
try {
|
||||||
// 디렉토리가 없으면 빈 배열 반환
|
// 디렉토리가 없으면 빈 배열 반환
|
||||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||||
console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
|
// console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|
@ -124,6 +124,13 @@ class MailSentHistoryService {
|
||||||
// 필터링
|
// 필터링
|
||||||
let filtered = allHistory;
|
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") {
|
if (status !== "all") {
|
||||||
filtered = filtered.filter((h) => h.status === status);
|
filtered = filtered.filter((h) => h.status === status);
|
||||||
|
|
@ -209,9 +216,151 @@ class MailSentHistoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 발송 이력 삭제
|
* 임시 저장 (Draft)
|
||||||
|
*/
|
||||||
|
async saveDraft(
|
||||||
|
data: Partial<SentMailHistory> & { accountId: string }
|
||||||
|
): Promise<SentMailHistory> {
|
||||||
|
// 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<SentMailHistory>
|
||||||
|
): Promise<SentMailHistory | null> {
|
||||||
|
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<boolean> {
|
async deleteSentMail(id: string): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
|
@ -220,14 +369,57 @@ class MailSentHistoryService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
console.log("🗑️ 발송 이력 삭제:", id);
|
// console.log("🗑️ 메일 영구 삭제:", id);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("발송 이력 삭제 실패:", error);
|
console.error("메일 영구 삭제 실패:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 30일 이상 지난 삭제된 메일 자동 영구 삭제
|
||||||
|
*/
|
||||||
|
async cleanupOldDeletedMails(): Promise<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 조회
|
* 통계 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,25 @@ class MailTemplateFileService {
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/app/uploads/mail-templates"
|
? "/app/uploads/mail-templates"
|
||||||
: path.join(process.cwd(), "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() {
|
private async ensureDirectoryExists() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,8 +89,6 @@ class MailTemplateFileService {
|
||||||
* 모든 템플릿 목록 조회
|
* 모든 템플릿 목록 조회
|
||||||
*/
|
*/
|
||||||
async getAllTemplates(): Promise<MailTemplate[]> {
|
async getAllTemplates(): Promise<MailTemplate[]> {
|
||||||
await this.ensureDirectoryExists();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(this.templatesDir);
|
const files = await fs.readdir(this.templatesDir);
|
||||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
||||||
|
|
@ -97,6 +109,7 @@ class MailTemplateFileService {
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 디렉토리가 없거나 읽기 실패 시 빈 배열 반환
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +173,7 @@ class MailTemplateFileService {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
// // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
this.getTemplatePath(id),
|
this.getTemplatePath(id),
|
||||||
|
|
@ -168,7 +181,7 @@ class MailTemplateFileService {
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
// // console.log(`✅ 템플릿 저장 성공: ${id}`);
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,35 @@ export class RiskAlertCacheService {
|
||||||
*/
|
*/
|
||||||
public startAutoRefresh(): void {
|
public startAutoRefresh(): void {
|
||||||
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
|
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
|
||||||
|
console.log(' - 기상특보: 즉시 호출');
|
||||||
|
console.log(' - 교통사고/도로공사: 10분 후 첫 호출');
|
||||||
|
|
||||||
// 즉시 첫 갱신
|
// 기상특보만 즉시 호출 (ITS API는 10분 후부터)
|
||||||
this.refreshCache();
|
this.refreshWeatherOnly();
|
||||||
|
|
||||||
// 10분마다 갱신 (600,000ms)
|
// 10분마다 전체 갱신 (600,000ms)
|
||||||
this.updateInterval = setInterval(() => {
|
this.updateInterval = setInterval(() => {
|
||||||
this.refreshCache();
|
this.refreshCache();
|
||||||
}, 10 * 60 * 1000);
|
}, 10 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기상특보만 갱신 (재시작 시 사용)
|
||||||
|
*/
|
||||||
|
private async refreshWeatherOnly(): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 갱신 중지
|
* 자동 갱신 중지
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,9 @@ export class ScreenManagementService {
|
||||||
// 화면 생성 (Raw Query)
|
// 화면 생성 (Raw Query)
|
||||||
const [screen] = await query<any>(
|
const [screen] = await query<any>(
|
||||||
`INSERT INTO screen_definitions (
|
`INSERT INTO screen_definitions (
|
||||||
screen_name, screen_code, table_name, company_code, description, created_by
|
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
db_source_type, db_connection_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
screenData.screenName,
|
screenData.screenName,
|
||||||
|
|
@ -81,6 +82,8 @@ export class ScreenManagementService {
|
||||||
screenData.companyCode,
|
screenData.companyCode,
|
||||||
screenData.description || null,
|
screenData.description || null,
|
||||||
screenData.createdBy,
|
screenData.createdBy,
|
||||||
|
screenData.dbSourceType || "internal",
|
||||||
|
screenData.dbConnectionId || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1779,6 +1782,8 @@ export class ScreenManagementService {
|
||||||
createdBy: data.created_by,
|
createdBy: data.created_by,
|
||||||
updatedDate: data.updated_date,
|
updatedDate: data.updated_date,
|
||||||
updatedBy: data.updated_by,
|
updatedBy: data.updated_by,
|
||||||
|
dbSourceType: data.db_source_type || "internal",
|
||||||
|
dbConnectionId: data.db_connection_id || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3118,4 +3118,410 @@ export class TableManagementService {
|
||||||
// 기본값
|
// 기본값
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 🎯 테이블 로그 시스템
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
async createLogTable(
|
||||||
|
tableName: string,
|
||||||
|
pkColumn: { columnName: string; dataType: string },
|
||||||
|
userId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<any>(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<any>(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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,11 +155,16 @@ export class TodoService {
|
||||||
updates: Partial<TodoItem>
|
updates: Partial<TodoItem>
|
||||||
): Promise<TodoItem> {
|
): Promise<TodoItem> {
|
||||||
try {
|
try {
|
||||||
if (DATA_SOURCE === "database") {
|
// 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기
|
||||||
|
try {
|
||||||
return await this.updateTodoDB(id, updates);
|
return await this.updateTodoDB(id, updates);
|
||||||
} else {
|
} catch (dbError: any) {
|
||||||
|
// 데이터베이스에서 찾지 못했으면 파일에서 찾기
|
||||||
|
if (dbError.message && dbError.message.includes("찾을 수 없습니다")) {
|
||||||
return this.updateTodoFile(id, updates);
|
return this.updateTodoFile(id, updates);
|
||||||
}
|
}
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ To-Do 수정 오류:", error);
|
logger.error("❌ To-Do 수정 오류:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -171,10 +176,16 @@ export class TodoService {
|
||||||
*/
|
*/
|
||||||
public async deleteTodo(id: string): Promise<void> {
|
public async deleteTodo(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (DATA_SOURCE === "database") {
|
// 먼저 데이터베이스에서 찾아보고, 없으면 파일에서 찾기
|
||||||
|
try {
|
||||||
await this.deleteTodoDB(id);
|
await this.deleteTodoDB(id);
|
||||||
} else {
|
} catch (dbError: any) {
|
||||||
|
// 데이터베이스에서 찾지 못했으면 파일에서 찾기
|
||||||
|
if (dbError.message && dbError.message.includes("찾을 수 없습니다")) {
|
||||||
this.deleteTodoFile(id);
|
this.deleteTodoFile(id);
|
||||||
|
} else {
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.info(`✅ To-Do 삭제: ${id}`);
|
logger.info(`✅ To-Do 삭제: ${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,17 @@ export interface DashboardElement {
|
||||||
layoutId: number;
|
layoutId: number;
|
||||||
layoutName?: string;
|
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 {
|
export interface Dashboard {
|
||||||
|
|
|
||||||
|
|
@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
@ -8,6 +8,8 @@ export interface FlowDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||||
|
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
@ -19,6 +21,8 @@ export interface CreateFlowDefinitionRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
|
||||||
|
dbConnectionId?: number; // 외부 DB 연결 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 플로우 정의 수정 요청
|
// 플로우 정의 수정 요청
|
||||||
|
|
@ -178,6 +182,9 @@ export interface FlowAuditLog {
|
||||||
targetDataId?: string;
|
targetDataId?: string;
|
||||||
statusFrom?: string;
|
statusFrom?: string;
|
||||||
statusTo?: string;
|
statusTo?: string;
|
||||||
|
// 외부 DB 연결 정보
|
||||||
|
dbConnectionId?: number;
|
||||||
|
dbConnectionName?: string;
|
||||||
// 조인 필드
|
// 조인 필드
|
||||||
fromStepName?: string;
|
fromStepName?: string;
|
||||||
toStepName?: string;
|
toStepName?: string;
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,18 @@ export interface SentMailHistory {
|
||||||
|
|
||||||
// 발송 정보
|
// 발송 정보
|
||||||
sentAt: string; // 발송 시간 (ISO 8601)
|
sentAt: string; // 발송 시간 (ISO 8601)
|
||||||
status: 'success' | 'failed'; // 발송 상태
|
status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가)
|
||||||
messageId?: string; // SMTP 메시지 ID (성공 시)
|
messageId?: string; // SMTP 메시지 ID (성공 시)
|
||||||
errorMessage?: string; // 오류 메시지 (실패 시)
|
errorMessage?: string; // 오류 메시지 (실패 시)
|
||||||
|
|
||||||
// 발송 결과
|
// 발송 결과
|
||||||
accepted?: string[]; // 수락된 이메일 주소
|
accepted?: string[]; // 수락된 이메일 주소
|
||||||
rejected?: string[]; // 거부된 이메일 주소
|
rejected?: string[]; // 거부된 이메일 주소
|
||||||
|
|
||||||
|
// 임시 저장 및 삭제
|
||||||
|
isDraft?: boolean; // 임시 저장 여부
|
||||||
|
deletedAt?: string; // 삭제 시간 (ISO 8601)
|
||||||
|
updatedAt?: string; // 수정 시간 (ISO 8601)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentInfo {
|
export interface AttachmentInfo {
|
||||||
|
|
@ -45,12 +50,14 @@ export interface SentMailListQuery {
|
||||||
page?: number; // 페이지 번호 (1부터 시작)
|
page?: number; // 페이지 번호 (1부터 시작)
|
||||||
limit?: number; // 페이지당 항목 수
|
limit?: number; // 페이지당 항목 수
|
||||||
searchTerm?: string; // 검색어 (제목, 받는사람)
|
searchTerm?: string; // 검색어 (제목, 받는사람)
|
||||||
status?: 'success' | 'failed' | 'all'; // 필터: 상태
|
status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가)
|
||||||
accountId?: string; // 필터: 발송 계정
|
accountId?: string; // 필터: 발송 계정
|
||||||
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
||||||
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
||||||
sortBy?: 'sentAt' | 'subject'; // 정렬 기준
|
sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가)
|
||||||
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
||||||
|
includeDeleted?: boolean; // 삭제된 메일 포함 여부
|
||||||
|
onlyDeleted?: boolean; // 삭제된 메일만 조회
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentMailListResponse {
|
export interface SentMailListResponse {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ export interface ScreenDefinition {
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
updatedDate: Date;
|
updatedDate: Date;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
|
dbSourceType?: "internal" | "external";
|
||||||
|
dbConnectionId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 생성 요청
|
// 화면 생성 요청
|
||||||
|
|
@ -161,6 +163,8 @@ export interface CreateScreenRequest {
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
|
dbSourceType?: "internal" | "external";
|
||||||
|
dbConnectionId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 수정 요청
|
// 화면 수정 요청
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
# syntax=docker/dockerfile:1
|
# Base image (WACE Docker Hub)
|
||||||
|
FROM dockerhub.wace.me/node:20.19-alpine.linux AS base
|
||||||
# Base image (Debian-based for glibc + OpenSSL compatibility)
|
|
||||||
FROM node:20-bookworm-slim AS base
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
# Install OpenSSL, curl (for healthcheck), and required certs
|
# Install OpenSSL, curl (for healthcheck), and required certs
|
||||||
RUN apt-get update \
|
RUN apk add --no-cache openssl ca-certificates curl
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Dependencies stage (install production dependencies)
|
# Dependencies stage (install production dependencies)
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
@ -15,7 +11,7 @@ COPY package*.json ./
|
||||||
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
|
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
|
||||||
|
|
||||||
# Build stage (compile TypeScript)
|
# Build stage (compile TypeScript)
|
||||||
FROM node:20-bookworm-slim AS build
|
FROM dockerhub.wace.me/node:20.19-alpine.linux AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
|
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
|
||||||
|
|
@ -27,8 +23,8 @@ RUN npm run build
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user (Alpine 방식)
|
||||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
RUN addgroup -S appgroup && adduser -S -G appgroup appuser
|
||||||
|
|
||||||
# Copy production node_modules
|
# Copy production node_modules
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,18 @@ services:
|
||||||
context: ../../backend-node
|
context: ../../backend-node
|
||||||
dockerfile: ../docker/prod/backend.Dockerfile
|
dockerfile: ../docker/prod/backend.Dockerfile
|
||||||
container_name: plm-backend
|
container_name: plm-backend
|
||||||
restart: always
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
- NODE_ENV=production
|
||||||
PORT: "3001"
|
- PORT=8080
|
||||||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
||||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||||
JWT_EXPIRES_IN: 24h
|
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||||
CORS_ORIGIN: https://v1.vexplor.com
|
- JWT_EXPIRES_IN=24h
|
||||||
CORS_CREDENTIALS: "true"
|
- CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771
|
||||||
LOG_LEVEL: info
|
- CORS_CREDENTIALS=true
|
||||||
volumes:
|
- LOG_LEVEL=info
|
||||||
- /home/vexplor/backend_data:/app/uploads
|
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||||
labels:
|
restart: unless-stopped
|
||||||
- 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
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Multi-stage build for Next.js
|
# Multi-stage build for Next.js
|
||||||
FROM node:18-alpine AS base
|
FROM dockerhub.wace.me/node:20.19-alpine.linux AS base
|
||||||
|
|
||||||
# curl 설치 (헬스체크용)
|
# curl 설치 (헬스체크용)
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
# 관리자 페이지 스타일 가이드 적용 예시
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
사용자 관리 페이지를 예시로 shadcn/ui 스타일 가이드에 맞춰 재작성했습니다.
|
||||||
|
이 예시를 기준으로 다른 관리자 페이지들도 일관된 스타일로 통일할 수 있습니다.
|
||||||
|
|
||||||
|
## 적용된 주요 원칙
|
||||||
|
|
||||||
|
### 1. Color System (색상 시스템)
|
||||||
|
|
||||||
|
**CSS Variables 사용 (하드코딩된 색상 금지)**
|
||||||
|
```tsx
|
||||||
|
// ❌ 잘못된 예시
|
||||||
|
<div className="bg-gray-50 text-gray-900">
|
||||||
|
|
||||||
|
// ✅ 올바른 예시
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
<div className="bg-card text-card-foreground">
|
||||||
|
<div className="bg-muted text-muted-foreground">
|
||||||
|
<div className="text-primary">
|
||||||
|
<div className="text-destructive">
|
||||||
|
```
|
||||||
|
|
||||||
|
**적용 사례:**
|
||||||
|
- 페이지 배경: `bg-background`
|
||||||
|
- 카드 배경: `bg-card`
|
||||||
|
- 보조 텍스트: `text-muted-foreground`
|
||||||
|
- 주요 액션: `text-primary`, `border-primary`
|
||||||
|
- 에러 메시지: `text-destructive`, `bg-destructive/10`
|
||||||
|
|
||||||
|
### 2. Typography (타이포그래피)
|
||||||
|
|
||||||
|
**일관된 폰트 크기와 가중치**
|
||||||
|
```tsx
|
||||||
|
// 페이지 제목
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
||||||
|
|
||||||
|
// 섹션 제목
|
||||||
|
<h4 className="text-sm font-semibold">고급 검색 옵션</h4>
|
||||||
|
|
||||||
|
// 본문 텍스트
|
||||||
|
<p className="text-sm text-muted-foreground">설명 텍스트</p>
|
||||||
|
|
||||||
|
// 라벨
|
||||||
|
<label className="text-sm font-medium">필드 라벨</label>
|
||||||
|
|
||||||
|
// 보조 텍스트
|
||||||
|
<p className="text-xs text-muted-foreground">도움말</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Spacing System (간격)
|
||||||
|
|
||||||
|
**일관된 간격 사용 (4px 기준)**
|
||||||
|
```tsx
|
||||||
|
// 컴포넌트 간 간격
|
||||||
|
<div className="space-y-6"> // 24px (페이지 레벨)
|
||||||
|
<div className="space-y-4"> // 16px (섹션 레벨)
|
||||||
|
<div className="space-y-2"> // 8px (필드 레벨)
|
||||||
|
|
||||||
|
// 패딩
|
||||||
|
<div className="p-6"> // 24px (카드)
|
||||||
|
<div className="p-4"> // 16px (내부 섹션)
|
||||||
|
|
||||||
|
// 갭
|
||||||
|
<div className="gap-4"> // 16px (flex/grid)
|
||||||
|
<div className="gap-2"> // 8px (버튼 그룹)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Border & Radius (테두리 및 둥근 모서리)
|
||||||
|
|
||||||
|
**표준 radius 사용**
|
||||||
|
```tsx
|
||||||
|
// 카드/패널
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
|
||||||
|
// 입력 필드
|
||||||
|
<Input className="rounded-md">
|
||||||
|
|
||||||
|
// 버튼
|
||||||
|
<Button className="rounded-md">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Button Variants (버튼 스타일)
|
||||||
|
|
||||||
|
**표준 variants 사용**
|
||||||
|
```tsx
|
||||||
|
// Primary 액션
|
||||||
|
<Button variant="default" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
사용자 등록
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Secondary 액션
|
||||||
|
<Button variant="outline" size="default" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
고급 검색
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Ghost 버튼 (아이콘 전용)
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**크기 표준:**
|
||||||
|
- `h-10`: 기본 버튼 (40px)
|
||||||
|
- `h-9`: 작은 버튼 (36px)
|
||||||
|
- `h-8`: 아이콘 버튼 (32px)
|
||||||
|
|
||||||
|
### 6. Input States (입력 필드 상태)
|
||||||
|
|
||||||
|
**표준 Input 스타일**
|
||||||
|
```tsx
|
||||||
|
// 기본
|
||||||
|
<Input className="h-10 text-sm" />
|
||||||
|
|
||||||
|
// 포커스 (자동 적용)
|
||||||
|
// focus:ring-2 focus:ring-ring
|
||||||
|
|
||||||
|
// 로딩/액티브
|
||||||
|
<Input className="h-10 text-sm border-primary ring-2 ring-primary/20" />
|
||||||
|
|
||||||
|
// 비활성화
|
||||||
|
<Input disabled className="cursor-not-allowed bg-muted text-muted-foreground" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Form Structure (폼 구조)
|
||||||
|
|
||||||
|
**표준 필드 구조**
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="field-id" className="text-sm font-medium">
|
||||||
|
필드 라벨
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="field-id"
|
||||||
|
placeholder="힌트 텍스트"
|
||||||
|
className="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
도움말 텍스트
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Table Structure (테이블 구조)
|
||||||
|
|
||||||
|
**표준 테이블 스타일**
|
||||||
|
```tsx
|
||||||
|
<div className="rounded-lg border bg-card shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">
|
||||||
|
컬럼명
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-16 text-sm">
|
||||||
|
데이터
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**높이 표준:**
|
||||||
|
- 헤더: `h-12` (48px)
|
||||||
|
- 데이터 행: `h-16` (64px)
|
||||||
|
|
||||||
|
### 9. Loading States (로딩 상태)
|
||||||
|
|
||||||
|
**Skeleton UI**
|
||||||
|
```tsx
|
||||||
|
<div className="h-4 animate-pulse rounded bg-muted"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Empty States (빈 상태)
|
||||||
|
|
||||||
|
**표준 Empty State**
|
||||||
|
```tsx
|
||||||
|
<TableCell colSpan={columns} className="h-32 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<p className="text-sm">등록된 데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. Error States (에러 상태)
|
||||||
|
|
||||||
|
**표준 에러 메시지**
|
||||||
|
```tsx
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-destructive transition-colors hover:text-destructive/80"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm text-destructive/80">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. Responsive Design (반응형)
|
||||||
|
|
||||||
|
**모바일 우선 접근**
|
||||||
|
```tsx
|
||||||
|
// 레이아웃
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
|
||||||
|
// 그리드
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
|
// 텍스트
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold">
|
||||||
|
|
||||||
|
// 간격
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13. Accessibility (접근성)
|
||||||
|
|
||||||
|
**필수 적용 사항**
|
||||||
|
```tsx
|
||||||
|
// Label과 Input 연결
|
||||||
|
<label htmlFor="user-id" className="text-sm font-medium">
|
||||||
|
사용자 ID
|
||||||
|
</label>
|
||||||
|
<Input id="user-id" />
|
||||||
|
|
||||||
|
// 버튼에 aria-label
|
||||||
|
<Button aria-label="에러 메시지 닫기">
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Switch에 aria-label
|
||||||
|
<Switch
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={handleChange}
|
||||||
|
aria-label="사용자 상태 토글"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 페이지 구조 템플릿
|
||||||
|
|
||||||
|
### Page Component
|
||||||
|
```tsx
|
||||||
|
export default function AdminPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="container mx-auto space-y-6 p-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">페이지 제목</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">페이지 설명</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<MainComponent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toolbar Component
|
||||||
|
```tsx
|
||||||
|
export function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="검색..." className="h-10 pl-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<Button variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
고급 검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 영역 */}
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총 <span className="font-semibold text-foreground">{count.toLocaleString()}</span> 건
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 적용해야 할 다른 관리자 페이지
|
||||||
|
|
||||||
|
### 우선순위 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: 각 스타일 요소별 상세 가이드
|
||||||
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -26,6 +17,7 @@ import {
|
||||||
BatchMapping,
|
BatchMapping,
|
||||||
} from "@/lib/api/batch";
|
} from "@/lib/api/batch";
|
||||||
import BatchCard from "@/components/admin/BatchCard";
|
import BatchCard from "@/components/admin/BatchCard";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
export default function BatchManagementPage() {
|
export default function BatchManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -178,76 +170,84 @@ export default function BatchManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4 space-y-2">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
{/* 헤더 */}
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* 페이지 헤더 */}
|
||||||
<div>
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold">배치 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">배치 관리</h1>
|
||||||
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
<p className="text-sm text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateBatch}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>배치 추가</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 액션 영역 */}
|
||||||
<Card>
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<CardContent className="py-2">
|
{/* 검색 영역 */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="flex-1 relative">
|
<div className="w-full sm:w-[400px]">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="배치명 또는 설명으로 검색..."
|
placeholder="배치명 또는 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
className="pl-10"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={loadBatchConfigs}
|
onClick={loadBatchConfigs}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center space-x-2"
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
<span>새로고침</span>
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{/* 액션 버튼 영역 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{batchConfigs.length.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
건
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateBatch}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
배치 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 배치 목록 */}
|
{/* 배치 목록 */}
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span>배치 목록 ({batchConfigs.length}개)</span>
|
|
||||||
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{batchConfigs.length === 0 ? (
|
{batchConfigs.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<h3 className="text-lg font-semibold mb-2">배치가 없습니다</h3>
|
<Database className="h-12 w-12 text-muted-foreground" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
{!searchTerm && (
|
{!searchTerm && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateBatch}
|
onClick={handleCreateBatch}
|
||||||
className="flex items-center space-x-2"
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>첫 번째 배치 추가</span>
|
첫 번째 배치 추가
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{batchConfigs.map((batch) => (
|
{batchConfigs.map((batch) => (
|
||||||
<BatchCard
|
<BatchCard
|
||||||
key={batch.id}
|
key={batch.id}
|
||||||
|
|
@ -255,7 +255,6 @@ export default function BatchManagementPage() {
|
||||||
executingBatch={executingBatch}
|
executingBatch={executingBatch}
|
||||||
onExecute={executeBatch}
|
onExecute={executeBatch}
|
||||||
onToggleStatus={(batchId, currentStatus) => {
|
onToggleStatus={(batchId, currentStatus) => {
|
||||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
|
|
||||||
toggleBatchStatus(batchId, currentStatus);
|
toggleBatchStatus(batchId, currentStatus);
|
||||||
}}
|
}}
|
||||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||||
|
|
@ -265,29 +264,28 @@ export default function BatchManagementPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
|
className="h-10 text-sm font-medium"
|
||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center gap-1">
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
const pageNum = i + 1;
|
const pageNum = i + 1;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className="h-10 min-w-[40px] text-sm"
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -299,6 +297,7 @@ export default function BatchManagementPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
|
className="h-10 text-sm font-medium"
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -307,58 +306,62 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
{/* 배치 타입 선택 모달 */}
|
{/* 배치 타입 선택 모달 */}
|
||||||
{isBatchTypeModalOpen && (
|
{isBatchTypeModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
<Card className="w-full max-w-2xl mx-4">
|
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
||||||
<CardHeader>
|
<div className="space-y-6">
|
||||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* DB → DB */}
|
{/* DB → DB */}
|
||||||
<div
|
<button
|
||||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
<Database className="h-8 w-8 text-primary" />
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<span className="text-muted-foreground">→</span>
|
||||||
<Database className="w-8 h-8 text-blue-600" />
|
<Database className="h-8 w-8 text-primary" />
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
|
||||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<div className="text-lg font-medium">DB → DB</div>
|
||||||
|
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* REST API → DB */}
|
{/* REST API → DB */}
|
||||||
<div
|
<button
|
||||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
<span className="text-2xl">🌐</span>
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<span className="text-muted-foreground">→</span>
|
||||||
<Database className="w-8 h-8 text-green-600" />
|
<Database className="h-8 w-8 text-primary" />
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
|
||||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<div className="text-lg font-medium">REST API → DB</div>
|
||||||
|
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||||
|
className="h-10 text-sm font-medium"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,59 +1,49 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
|
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
|
||||||
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
|
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
|
||||||
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
|
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
|
||||||
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
export default function CommonCodeManagementPage() {
|
export default function CommonCodeManagementPage() {
|
||||||
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
|
|
||||||
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">공통코드 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">공통코드 관리</h1>
|
<p className="text-sm text-muted-foreground">시스템에서 사용하는 공통코드를 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">시스템에서 사용하는 공통코드를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 - 좌우 레이아웃 */}
|
||||||
{/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */}
|
<div className="flex flex-col gap-6 lg:flex-row lg:gap-6">
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
{/* 좌측: 카테고리 패널 */}
|
||||||
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
|
<div className="w-full lg:w-80 lg:border-r lg:pr-6">
|
||||||
<div className="w-full lg:w-80 lg:flex-shrink-0">
|
<div className="space-y-4">
|
||||||
<Card className="h-full shadow-sm">
|
<h2 className="text-lg font-semibold">코드 카테고리</h2>
|
||||||
<CardHeader className="bg-gray-50/50">
|
|
||||||
<CardTitle className="flex items-center gap-2">📂 코드 카테고리</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */}
|
{/* 우측: 코드 상세 패널 */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 lg:pl-0">
|
||||||
<Card className="h-fit shadow-sm">
|
<div className="space-y-4">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<h2 className="text-lg font-semibold">
|
||||||
<CardTitle className="flex items-center gap-2">
|
코드 상세 정보
|
||||||
📋 코드 상세 정보
|
|
||||||
{selectedCategoryCode && (
|
{selectedCategoryCode && (
|
||||||
<span className="text-muted-foreground text-sm font-normal">({selectedCategoryCode})</span>
|
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
<CodeDetailPanel categoryCode={selectedCategoryCode} />
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회사 관리 페이지
|
* 회사 관리 페이지
|
||||||
*/
|
*/
|
||||||
export default function CompanyPage() {
|
export default function CompanyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">회사 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">회사 관리</h1>
|
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
<CompanyManagement />
|
<CompanyManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -25,8 +23,9 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react";
|
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() {
|
export default function DashboardListPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
// 페이지네이션 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||||
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
|
||||||
|
|
||||||
// 대시보드 목록 로드
|
// 대시보드 목록 로드
|
||||||
const loadDashboards = async () => {
|
const loadDashboards = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
const result = await dashboardApi.getMyDashboards({
|
||||||
const result = await dashboardApi.getMyDashboards({ search: searchTerm });
|
search: searchTerm,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
setDashboards(result.dashboards);
|
setDashboards(result.dashboards);
|
||||||
|
setTotalCount(result.pagination.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load dashboards:", err);
|
console.error("Failed to load dashboards:", err);
|
||||||
setError("대시보드 목록을 불러오는데 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 목록을 불러오는데 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +73,29 @@ export default function DashboardListPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboards();
|
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) => {
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
|
|
@ -79,37 +111,48 @@ export default function DashboardListPage() {
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setSuccessMessage("대시보드가 삭제되었습니다.");
|
toast({
|
||||||
setSuccessDialogOpen(true);
|
title: "성공",
|
||||||
|
description: "대시보드가 삭제되었습니다.",
|
||||||
|
});
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete dashboard:", err);
|
console.error("Failed to delete dashboard:", err);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setError("대시보드 삭제에 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 삭제에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 복사
|
// 대시보드 복사
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
try {
|
try {
|
||||||
// 전체 대시보드 정보(요소 포함)를 가져오기
|
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
const newDashboard = await dashboardApi.createDashboard({
|
await dashboardApi.createDashboard({
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
description: fullDashboard.description,
|
description: fullDashboard.description,
|
||||||
elements: fullDashboard.elements || [],
|
elements: fullDashboard.elements || [],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
tags: fullDashboard.tags,
|
tags: fullDashboard.tags,
|
||||||
category: fullDashboard.category,
|
category: fullDashboard.category,
|
||||||
settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 복사되었습니다.",
|
||||||
});
|
});
|
||||||
setSuccessMessage("대시보드가 복사되었습니다.");
|
|
||||||
setSuccessDialogOpen(true);
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy dashboard:", err);
|
console.error("Failed to copy dashboard:", err);
|
||||||
setError("대시보드 복사에 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 복사에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,109 +162,99 @@ export default function DashboardListPage() {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-medium text-gray-900">로딩 중...</div>
|
<div className="text-sm font-medium">로딩 중...</div>
|
||||||
<div className="mt-2 text-sm text-gray-500">대시보드 목록을 불러오고 있습니다</div>
|
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-gray-50 p-6">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="space-y-6 p-6">
|
||||||
{/* 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="mb-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">대시보드 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||||
<p className="mt-2 text-sm text-gray-600">대시보드를 생성하고 관리할 수 있습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 바 */}
|
{/* 검색 및 액션 */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="relative w-64">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="대시보드 검색..."
|
placeholder="대시보드 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<Card className="mb-6 border-red-200 bg-red-50 p-4">
|
|
||||||
<p className="text-sm text-red-800">{error}</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
{/* 대시보드 목록 */}
|
||||||
{dashboards.length === 0 ? (
|
{dashboards.length === 0 ? (
|
||||||
<Card className="p-12 text-center">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Plus className="h-12 w-12 text-gray-400" />
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-2 text-lg font-medium text-gray-900">대시보드가 없습니다</h3>
|
|
||||||
<p className="mb-6 text-sm text-gray-500">첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요</p>
|
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead>제목</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
<TableHead>설명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
<TableHead>생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
<TableHead>수정일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
<TableHead className="w-[80px]">작업</TableHead>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{dashboards.map((dashboard) => (
|
{dashboards.map((dashboard) => (
|
||||||
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="font-medium">{dashboard.title}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||||
<TableCell className="max-w-md truncate text-sm text-gray-500">
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
{dashboard.description || "-"}
|
{dashboard.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
|
{formatDate(dashboard.createdAt)}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||||
className="gap-2"
|
className="gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
편집
|
편집
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2">
|
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
복사
|
복사
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
onClick={() => 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"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
|
|
@ -233,44 +266,42 @@ export default function DashboardListPage() {
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && dashboards.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
{/* 삭제 확인 모달 */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>대시보드 삭제</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-red-600 hover:bg-red-700">
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 성공 모달 */}
|
|
||||||
<Dialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
||||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-center">완료</DialogTitle>
|
|
||||||
<DialogDescription className="text-center">{successMessage}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<Button onClick={() => setSuccessDialogOpen(false)}>확인</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
type Step = "list" | "editor";
|
type Step = "list" | "editor";
|
||||||
|
|
@ -50,17 +51,17 @@ export default function DataFlowPage() {
|
||||||
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||||
if (isEditorMode) {
|
if (isEditorMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-white">
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 에디터 헤더 */}
|
{/* 에디터 헤더 */}
|
||||||
<div className="flex items-center gap-4 border-b bg-white p-4">
|
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">노드 플로우 에디터</h1>
|
<h1 className="text-2xl font-bold tracking-tight">노드 플로우 에디터</h1>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,19 +77,20 @@ export default function DataFlowPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="mx-auto space-y-4 px-5 py-4">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">제어 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">제어 관리</h1>
|
<p className="text-sm text-muted-foreground">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">노드 기반 데이터 플로우를 시각적으로 설계하고 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 목록 */}
|
{/* 플로우 목록 */}
|
||||||
<DataFlowList onLoadFlow={handleLoadFlow} />
|
<DataFlowList onLoadFlow={handleLoadFlow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,47 +161,44 @@ export default function ExternalCallConfigsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">외부 호출 관리</h1>
|
||||||
<h1 className="text-3xl font-bold">외부 호출 관리</h1>
|
<p className="text-sm text-muted-foreground">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
||||||
<p className="text-muted-foreground mt-1">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleAddConfig} className="flex items-center gap-2">
|
|
||||||
<Plus size={16} />새 외부 호출 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 영역 */}
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
{/* 첫 번째 줄: 검색 + 추가 버튼 */}
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Filter size={18} />
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
검색 및 필터
|
<div className="w-full sm:w-[320px]">
|
||||||
</CardTitle>
|
<div className="relative">
|
||||||
</CardHeader>
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="설정 이름 또는 설명으로 검색..."
|
placeholder="설정 이름 또는 설명으로 검색..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSearch} variant="outline">
|
</div>
|
||||||
<Search size={16} />
|
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 외부 호출 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 */}
|
{/* 두 번째 줄: 필터 */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium">호출 타입</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={filter.call_type || "all"}
|
value={filter.call_type || "all"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -211,8 +208,8 @@ export default function ExternalCallConfigsPage() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue />
|
<SelectValue placeholder="호출 타입" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">전체</SelectItem>
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
|
@ -223,10 +220,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium">API 타입</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={filter.api_type || "all"}
|
value={filter.api_type || "all"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -236,8 +230,8 @@ export default function ExternalCallConfigsPage() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue />
|
<SelectValue placeholder="API 타입" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">전체</SelectItem>
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
|
@ -248,10 +242,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium">상태</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={filter.is_active || "Y"}
|
value={filter.is_active || "Y"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -261,8 +252,8 @@ export default function ExternalCallConfigsPage() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue />
|
<SelectValue placeholder="상태" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||||
|
|
@ -274,92 +265,97 @@ export default function ExternalCallConfigsPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 설정 목록 */}
|
{/* 설정 목록 */}
|
||||||
<Card>
|
<div className="rounded-lg border bg-card shadow-sm">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>외부 호출 설정 목록</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
<div className="py-8 text-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-muted-foreground">로딩 중...</div>
|
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : configs.length === 0 ? (
|
) : configs.length === 0 ? (
|
||||||
// 빈 상태
|
// 빈 상태
|
||||||
<div className="py-12 text-center">
|
<div className="flex h-64 flex-col items-center justify-center">
|
||||||
<div className="text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Plus size={48} className="mx-auto mb-4 opacity-20" />
|
<p className="text-sm text-muted-foreground">등록된 외부 호출 설정이 없습니다.</p>
|
||||||
<p className="text-lg font-medium">등록된 외부 호출 설정이 없습니다.</p>
|
<p className="text-xs text-muted-foreground">새 외부 호출을 추가해보세요.</p>
|
||||||
<p className="text-sm">새 외부 호출을 추가해보세요.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 설정 테이블 목록
|
// 설정 테이블 목록
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||||
<TableHead>설정명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설정명</TableHead>
|
||||||
<TableHead>호출 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">호출 타입</TableHead>
|
||||||
<TableHead>API 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">API 타입</TableHead>
|
||||||
<TableHead>설명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
<TableHead>상태</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">상태</TableHead>
|
||||||
<TableHead>생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
<TableHead className="text-center">작업</TableHead>
|
<TableHead className="h-12 text-center text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{configs.map((config) => (
|
{configs.map((config) => (
|
||||||
<TableRow key={config.id} className="hover:bg-muted/50">
|
<TableRow key={config.id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
<TableCell className="font-medium">{config.config_name}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
|
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
{config.api_type ? (
|
{config.api_type ? (
|
||||||
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
|
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="max-w-xs">
|
<div className="max-w-xs">
|
||||||
{config.description ? (
|
{config.description ? (
|
||||||
<span className="text-muted-foreground block truncate text-sm" title={config.description}>
|
<span className="block truncate text-muted-foreground" title={config.description}>
|
||||||
{config.description}
|
{config.description}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
|
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
|
||||||
{config.is_active === "Y" ? "활성" : "비활성"}
|
{config.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
<TableCell className="h-16 text-sm text-muted-foreground">
|
||||||
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
|
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="flex justify-center gap-1">
|
<div className="flex justify-center gap-1">
|
||||||
<Button size="sm" variant="outline" onClick={() => handleTestConfig(config)} title="테스트">
|
<Button
|
||||||
<TestTube size={14} />
|
variant="ghost"
|
||||||
</Button>
|
size="icon"
|
||||||
<Button size="sm" variant="outline" onClick={() => handleEditConfig(config)} title="편집">
|
className="h-8 w-8"
|
||||||
<Edit size={14} />
|
onClick={() => handleTestConfig(config)}
|
||||||
|
title="테스트"
|
||||||
|
>
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
variant="outline"
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEditConfig(config)}
|
||||||
|
title="편집"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
onClick={() => handleDeleteConfig(config)}
|
onClick={() => handleDeleteConfig(config)}
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -368,8 +364,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 외부 호출 설정 모달 */}
|
{/* 외부 호출 설정 모달 */}
|
||||||
<ExternalCallConfigModal
|
<ExternalCallConfigModal
|
||||||
|
|
@ -381,17 +376,22 @@ export default function ExternalCallConfigsPage() {
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>외부 호출 설정 삭제</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">외부 호출 설정 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
|
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
<AlertDialogAction onClick={confirmDeleteConfig} className="bg-destructive hover:bg-destructive/90">
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDeleteConfig}
|
||||||
|
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -27,6 +28,9 @@ import {
|
||||||
} from "@/lib/api/externalDbConnection";
|
} from "@/lib/api/externalDbConnection";
|
||||||
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
||||||
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
||||||
|
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
|
||||||
|
|
||||||
|
type ConnectionTabType = "database" | "rest-api";
|
||||||
|
|
||||||
// DB 타입 매핑
|
// DB 타입 매핑
|
||||||
const DB_TYPE_LABELS: Record<string, string> = {
|
const DB_TYPE_LABELS: Record<string, string> = {
|
||||||
|
|
@ -47,6 +51,9 @@ const ACTIVE_STATUS_OPTIONS = [
|
||||||
export default function ExternalConnectionsPage() {
|
export default function ExternalConnectionsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 탭 상태
|
||||||
|
const [activeTab, setActiveTab] = useState<ConnectionTabType>("database");
|
||||||
|
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -220,35 +227,46 @@ export default function ExternalConnectionsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">외부 커넥션 관리</h1>
|
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">외부 데이터베이스 연결 정보를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||||
|
<TabsList className="grid w-[400px] grid-cols-2">
|
||||||
|
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
데이터베이스 연결
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
REST API 연결
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 데이터베이스 연결 탭 */}
|
||||||
|
<TabsContent value="database" className="space-y-6">
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 */}
|
||||||
<Card className="mb-6 shadow-sm">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="연결명 또는 설명으로 검색..."
|
placeholder="연결명 또는 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-64 pl-10"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB 타입 필터 */}
|
{/* DB 타입 필터 */}
|
||||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<SelectValue placeholder="DB 타입" />
|
<SelectValue placeholder="DB 타입" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -262,7 +280,7 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
{/* 활성 상태 필터 */}
|
{/* 활성 상태 필터 */}
|
||||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||||
<SelectValue placeholder="상태" />
|
<SelectValue placeholder="상태" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -276,121 +294,109 @@ export default function ExternalConnectionsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<Button onClick={handleAddConnection} className="shrink-0">
|
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="mr-2 h-4 w-4" />새 연결 추가
|
<Plus className="h-4 w-4" />
|
||||||
|
새 연결 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 연결 목록 */}
|
{/* 연결 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
<div className="text-gray-500">로딩 중...</div>
|
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : connections.length === 0 ? (
|
) : connections.length === 0 ? (
|
||||||
<Card className="shadow-sm">
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<div className="py-8 text-center text-gray-500">
|
<p className="text-sm text-muted-foreground">등록된 연결이 없습니다</p>
|
||||||
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
</div>
|
||||||
<p className="mb-2 text-lg font-medium">등록된 연결이 없습니다</p>
|
|
||||||
<p className="mb-4 text-sm text-gray-400">새 외부 데이터베이스 연결을 추가해보세요.</p>
|
|
||||||
<Button onClick={handleAddConnection}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />첫 번째 연결 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="shadow-sm">
|
<div className="rounded-lg border bg-card shadow-sm">
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||||
<TableHead className="w-[200px]">연결명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">연결명</TableHead>
|
||||||
<TableHead className="w-[120px]">DB 타입</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">DB 타입</TableHead>
|
||||||
<TableHead className="w-[200px]">호스트:포트</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">호스트:포트</TableHead>
|
||||||
<TableHead className="w-[150px]">데이터베이스</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">데이터베이스</TableHead>
|
||||||
<TableHead className="w-[120px]">사용자</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">사용자</TableHead>
|
||||||
<TableHead className="w-[80px]">상태</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">상태</TableHead>
|
||||||
<TableHead className="w-[100px]">생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
<TableHead className="w-[100px]">연결 테스트</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">연결 테스트</TableHead>
|
||||||
<TableHead className="w-[120px] text-right">작업</TableHead>
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{connections.map((connection) => (
|
{connections.map((connection) => (
|
||||||
<TableRow key={connection.id} className="hover:bg-gray-50">
|
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
<div className="font-medium">{connection.connection_name}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline">
|
||||||
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="h-16 font-mono text-sm">
|
||||||
{connection.host}:{connection.port}
|
{connection.host}:{connection.port}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{connection.database_name}</TableCell>
|
<TableCell className="h-16 font-mono text-sm">{connection.database_name}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{connection.username}</TableCell>
|
<TableCell className="h-16 font-mono text-sm">{connection.username}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-xs">
|
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="h-16 text-sm">
|
||||||
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
|
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="h-16 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleTestConnection(connection)}
|
onClick={() => handleTestConnection(connection)}
|
||||||
disabled={testingConnections.has(connection.id!)}
|
disabled={testingConnections.has(connection.id!)}
|
||||||
className="h-7 px-2 text-xs"
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||||
</Button>
|
</Button>
|
||||||
{testResults.has(connection.id!) && (
|
{testResults.has(connection.id!) && (
|
||||||
<Badge
|
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||||
variant={testResults.get(connection.id!) ? "default" : "destructive"}
|
|
||||||
className="text-xs text-white"
|
|
||||||
>
|
|
||||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="h-16 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
|
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
|
||||||
setSelectedConnection(connection);
|
setSelectedConnection(connection);
|
||||||
setSqlModalOpen(true);
|
setSqlModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8"
|
||||||
title="SQL 쿼리 실행"
|
title="SQL 쿼리 실행"
|
||||||
>
|
>
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => handleEditConnection(connection)}
|
onClick={() => handleEditConnection(connection)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => handleDeleteConnection(connection)}
|
onClick={() => handleDeleteConnection(connection)}
|
||||||
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
className="h-8 w-8 text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -400,8 +406,7 @@ export default function ExternalConnectionsPage() {
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 연결 설정 모달 */}
|
{/* 연결 설정 모달 */}
|
||||||
|
|
@ -417,20 +422,25 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>연결 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
<span className="font-medium text-red-600">이 작업은 되돌릴 수 없습니다.</span>
|
이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel onClick={cancelDeleteConnection}>취소</AlertDialogCancel>
|
<AlertDialogCancel
|
||||||
|
onClick={cancelDeleteConnection}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteConnection}
|
onClick={confirmDeleteConnection}
|
||||||
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
|
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|
@ -450,6 +460,13 @@ export default function ExternalConnectionsPage() {
|
||||||
connectionName={selectedConnection.connection_name}
|
connectionName={selectedConnection.connection_name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* REST API 연결 탭 */}
|
||||||
|
<TabsContent value="rest-api" className="space-y-6">
|
||||||
|
<RestApiConnectionList />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ export default function FlowEditorPage() {
|
||||||
// 플로우 정의 로드
|
// 플로우 정의 로드
|
||||||
const flowRes = await getFlowDefinition(flowId);
|
const flowRes = await getFlowDefinition(flowId);
|
||||||
if (flowRes.success && flowRes.data) {
|
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() {
|
||||||
<FlowStepPanel
|
<FlowStepPanel
|
||||||
step={selectedStep}
|
step={selectedStep}
|
||||||
flowId={flowId}
|
flowId={flowId}
|
||||||
|
flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달
|
||||||
|
flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달
|
||||||
|
flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달
|
||||||
onClose={() => setSelectedStep(null)}
|
onClose={() => setSelectedStep(null)}
|
||||||
onUpdate={loadFlowData}
|
onUpdate={loadFlowData}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -27,6 +26,12 @@ import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
|
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
|
||||||
import { FlowDefinition } from "@/types/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() {
|
export default function FlowManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -39,6 +44,19 @@ export default function FlowManagementPage() {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
|
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
|
||||||
|
|
||||||
|
// 테이블 목록 관련 상태
|
||||||
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string; description?: string }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
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<string[]>([]);
|
||||||
|
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||||
|
|
||||||
// 생성 폼 상태
|
// 생성 폼 상태
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -60,10 +78,10 @@ export default function FlowManagementPage() {
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "오류 발생",
|
title: "오류 발생",
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -73,11 +91,113 @@ export default function FlowManagementPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFlows();
|
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 () => {
|
const handleCreate = async () => {
|
||||||
|
console.log("🚀 handleCreate called with formData:", formData);
|
||||||
|
|
||||||
if (!formData.name || !formData.tableName) {
|
if (!formData.name || !formData.tableName) {
|
||||||
|
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName });
|
||||||
toast({
|
toast({
|
||||||
title: "입력 오류",
|
title: "입력 오류",
|
||||||
description: "플로우 이름과 테이블 이름은 필수입니다.",
|
description: "플로우 이름과 테이블 이름은 필수입니다.",
|
||||||
|
|
@ -87,7 +207,15 @@ export default function FlowManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.success && response.data) {
|
||||||
toast({
|
toast({
|
||||||
title: "생성 완료",
|
title: "생성 완료",
|
||||||
|
|
@ -95,6 +223,7 @@ export default function FlowManagementPage() {
|
||||||
});
|
});
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
setFormData({ name: "", description: "", tableName: "" });
|
setFormData({ name: "", description: "", tableName: "" });
|
||||||
|
setSelectedDbSource("internal");
|
||||||
loadFlows();
|
loadFlows();
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -103,10 +232,10 @@ export default function FlowManagementPage() {
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "오류 발생",
|
title: "오류 발생",
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -133,10 +262,10 @@ export default function FlowManagementPage() {
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "오류 발생",
|
title: "오류 발생",
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -148,107 +277,128 @@ export default function FlowManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-4 p-3 sm:space-y-6 sm:p-4 lg:p-6">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
{/* 헤더 */}
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex-1">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="flex items-center gap-2 text-xl font-bold sm:text-2xl lg:text-3xl">
|
<h1 className="text-3xl font-bold tracking-tight">플로우 관리</h1>
|
||||||
<Workflow className="h-6 w-6 sm:h-7 sm:w-7 lg:h-8 lg:w-8" />
|
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||||
플로우 관리
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
{/* 액션 버튼 영역 */}
|
||||||
<span className="hidden sm:inline">새 플로우 생성</span>
|
<div className="flex items-center justify-end">
|
||||||
<span className="sm:hidden">생성</span>
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />새 플로우 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 카드 목록 */}
|
{/* 플로우 카드 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-8 text-center sm:py-12">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<p className="text-muted-foreground text-sm sm:text-base">로딩 중...</p>
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : flows.length === 0 ? (
|
) : flows.length === 0 ? (
|
||||||
<Card>
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<CardContent className="py-8 text-center sm:py-12">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<Workflow className="text-muted-foreground mx-auto mb-3 h-10 w-10 sm:mb-4 sm:h-12 sm:w-12" />
|
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
<p className="text-muted-foreground mb-3 text-sm sm:mb-4 sm:text-base">생성된 플로우가 없습니다</p>
|
<Workflow className="text-muted-foreground h-8 w-8" />
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
|
</div>
|
||||||
<Plus className="mr-2 h-4 w-4" />첫 플로우 만들기
|
<h3 className="text-lg font-semibold">생성된 플로우가 없습니다</h3>
|
||||||
|
<p className="text-muted-foreground max-w-sm text-sm">
|
||||||
|
새 플로우를 생성하여 업무 프로세스를 관리해보세요.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="mt-4 h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />첫 플로우 만들기
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{flows.map((flow) => (
|
{flows.map((flow) => (
|
||||||
<Card
|
<div
|
||||||
key={flow.id}
|
key={flow.id}
|
||||||
className="cursor-pointer transition-shadow hover:shadow-lg"
|
className="bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-6 shadow-sm transition-colors"
|
||||||
onClick={() => handleEdit(flow.id)}
|
onClick={() => handleEdit(flow.id)}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-4 sm:p-6">
|
{/* 헤더 */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="flex flex-col gap-1 text-base sm:flex-row sm:items-center sm:gap-2 sm:text-lg">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate">{flow.name}</span>
|
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
|
||||||
{flow.isActive && (
|
{flow.isActive && (
|
||||||
<Badge variant="success" className="self-start">
|
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600">활성</Badge>
|
||||||
활성
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1 line-clamp-2 text-xs sm:mt-2 sm:text-sm">
|
|
||||||
{flow.description || "설명 없음"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 pt-0 sm:p-6">
|
|
||||||
<div className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
|
|
||||||
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<Table className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
|
||||||
<span className="truncate">{flow.tableName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<User className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
|
||||||
<span className="truncate">생성자: {flow.createdBy}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<Calendar className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
|
||||||
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2 sm:mt-4">
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Table className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-muted-foreground truncate">{flow.tableName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<User className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-muted-foreground truncate">생성자: {flow.createdBy}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit(flow.id);
|
handleEdit(flow.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit2 className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
편집
|
편집
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2 text-xs sm:h-9 sm:px-3 sm:text-sm"
|
className="h-9 w-9 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedFlow(flow);
|
setSelectedFlow(flow);
|
||||||
setIsDeleteDialogOpen(true);
|
setIsDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -277,19 +427,123 @@ export default function FlowManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DB 소스 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">데이터베이스 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDbSource.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const dbSource = value === "internal" ? "internal" : parseInt(value);
|
||||||
|
setSelectedDbSource(dbSource);
|
||||||
|
// DB 소스 변경 시 테이블 선택 초기화
|
||||||
|
setFormData({ ...formData, tableName: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="데이터베이스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="internal">내부 데이터베이스</SelectItem>
|
||||||
|
{externalConnections.map((conn) => (
|
||||||
|
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||||
|
{conn.connection_name} ({conn.db_type?.toUpperCase()})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
플로우에서 사용할 데이터베이스를 선택합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
||||||
연결 테이블 *
|
연결 테이블 *
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
|
||||||
id="tableName"
|
<PopoverTrigger asChild>
|
||||||
value={formData.tableName}
|
<Button
|
||||||
onChange={(e) => setFormData({ ...formData, tableName: e.target.value })}
|
variant="outline"
|
||||||
placeholder="예: products"
|
role="combobox"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
aria-expanded={openTableCombobox}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
|
||||||
|
>
|
||||||
|
{formData.tableName
|
||||||
|
? selectedDbSource === "internal"
|
||||||
|
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
||||||
|
formData.tableName
|
||||||
|
: formData.tableName
|
||||||
|
: loadingTables || loadingExternalTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{selectedDbSource === "internal"
|
||||||
|
? // 내부 DB 테이블 목록
|
||||||
|
tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={table.tableName}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
console.log("📝 Internal table selected:", {
|
||||||
|
tableName: table.tableName,
|
||||||
|
currentValue,
|
||||||
|
});
|
||||||
|
setFormData({ ...formData, tableName: currentValue });
|
||||||
|
setOpenTableCombobox(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
|
{table.description && (
|
||||||
|
<span className="text-[10px] text-gray-500">{table.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))
|
||||||
|
: // 외부 DB 테이블 목록
|
||||||
|
externalTableList.map((tableName, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`external-${selectedDbSource}-${tableName}-${index}`}
|
||||||
|
value={tableName}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
setFormData({ ...formData, tableName: currentValue });
|
||||||
|
setOpenTableCombobox(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>{tableName}</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
플로우가 관리할 데이터 테이블 이름을 입력하세요
|
플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -329,7 +583,7 @@ export default function FlowManagementPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
|
정말로 “{selectedFlow?.name}” 플로우를 삭제하시겠습니까?
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -356,5 +610,9 @@ export default function FlowManagementPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ export default function MailAccountsPage() {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setAccounts(data);
|
setAccounts(data);
|
||||||
} else {
|
} else {
|
||||||
console.error('API 응답이 배열이 아닙니다:', data);
|
// console.error('API 응답이 배열이 아닙니다:', data);
|
||||||
setAccounts([]);
|
setAccounts([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('계정 로드 실패:', error);
|
// console.error('계정 로드 실패:', error);
|
||||||
setAccounts([]); // 에러 시 빈 배열로 설정
|
setAccounts([]); // 에러 시 빈 배열로 설정
|
||||||
// alert('계정 목록을 불러오는데 실패했습니다.');
|
// alert('계정 목록을 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -93,7 +93,7 @@ export default function MailAccountsPage() {
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
alert('계정이 삭제되었습니다.');
|
alert('계정이 삭제되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('계정 삭제 실패:', error);
|
// console.error('계정 삭제 실패:', error);
|
||||||
alert('계정 삭제에 실패했습니다.');
|
alert('계정 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -104,7 +104,7 @@ export default function MailAccountsPage() {
|
||||||
await updateMailAccount(account.id, { status: newStatus });
|
await updateMailAccount(account.id, { status: newStatus });
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('상태 변경 실패:', error);
|
// console.error('상태 변경 실패:', error);
|
||||||
alert('상태 변경에 실패했습니다.');
|
alert('상태 변경에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -120,7 +120,7 @@ export default function MailAccountsPage() {
|
||||||
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('연결 테스트 실패:', error);
|
// console.error('연결 테스트 실패:', error);
|
||||||
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,524 @@
|
||||||
|
"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 { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Send,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Download,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
MailAccount,
|
||||||
|
MailTemplate,
|
||||||
|
getMailAccounts,
|
||||||
|
getMailTemplates,
|
||||||
|
sendBulkMail,
|
||||||
|
} from "@/lib/api/mail";
|
||||||
|
|
||||||
|
interface RecipientData {
|
||||||
|
email: string;
|
||||||
|
variables: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkSendPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
|
const [useTemplate, setUseTemplate] = useState<boolean>(true); // 템플릿 사용 여부
|
||||||
|
const [customHtml, setCustomHtml] = useState<string>(""); // 직접 작성한 HTML
|
||||||
|
const [subject, setSubject] = useState<string>("");
|
||||||
|
const [recipients, setRecipients] = useState<RecipientData[]>([]);
|
||||||
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendProgress, setSendProgress] = useState({ sent: 0, total: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAccounts();
|
||||||
|
loadTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getMailAccounts();
|
||||||
|
setAccounts(data.filter((acc) => acc.status === 'active'));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "계정 로드 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getMailTemplates();
|
||||||
|
setTemplates(data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "템플릿 로드 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.name.endsWith(".csv")) {
|
||||||
|
toast({
|
||||||
|
title: "파일 형식 오류",
|
||||||
|
description: "CSV 파일만 업로드 가능합니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCsvFile(file);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split("\n").filter((line) => line.trim());
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
throw new Error("CSV 파일에 데이터가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 줄은 헤더
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim());
|
||||||
|
|
||||||
|
if (!headers.includes("email")) {
|
||||||
|
throw new Error("CSV 파일에 'email' 컬럼이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailIndex = headers.indexOf("email");
|
||||||
|
const variableHeaders = headers.filter((h) => h !== "email");
|
||||||
|
|
||||||
|
const parsedRecipients: RecipientData[] = lines.slice(1).map((line) => {
|
||||||
|
const values = line.split(",").map((v) => v.trim());
|
||||||
|
const email = values[emailIndex];
|
||||||
|
const variables: Record<string, string> = {};
|
||||||
|
|
||||||
|
variableHeaders.forEach((header, index) => {
|
||||||
|
const valueIndex = headers.indexOf(header);
|
||||||
|
variables[header] = values[valueIndex] || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return { email, variables };
|
||||||
|
});
|
||||||
|
|
||||||
|
setRecipients(parsedRecipients);
|
||||||
|
toast({
|
||||||
|
title: "파일 업로드 성공",
|
||||||
|
description: `${parsedRecipients.length}명의 수신자를 불러왔습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "파일 파싱 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setCsvFile(null);
|
||||||
|
setRecipients([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!selectedAccountId) {
|
||||||
|
toast({
|
||||||
|
title: "계정 선택 필요",
|
||||||
|
description: "발송할 메일 계정을 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 또는 직접 작성 중 하나는 있어야 함
|
||||||
|
if (useTemplate && !selectedTemplateId) {
|
||||||
|
toast({
|
||||||
|
title: "템플릿 선택 필요",
|
||||||
|
description: "사용할 템플릿을 선택해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useTemplate && !customHtml.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "내용 입력 필요",
|
||||||
|
description: "메일 내용을 입력해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "제목 입력 필요",
|
||||||
|
description: "메일 제목을 입력해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "수신자 없음",
|
||||||
|
description: "CSV 파일을 업로드해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setSendProgress({ sent: 0, total: recipients.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendBulkMail({
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
templateId: useTemplate ? selectedTemplateId : undefined,
|
||||||
|
customHtml: !useTemplate ? customHtml : undefined,
|
||||||
|
subject,
|
||||||
|
recipients,
|
||||||
|
onProgress: (sent, total) => {
|
||||||
|
setSendProgress({ sent, total });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "대량 발송 완료",
|
||||||
|
description: `${recipients.length}명에게 메일을 발송했습니다.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
setSelectedAccountId("");
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setSubject("");
|
||||||
|
setRecipients([]);
|
||||||
|
setCsvFile(null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "발송 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSampleCsv = () => {
|
||||||
|
const sample = `email,name,company
|
||||||
|
example1@example.com,홍길동,ABC회사
|
||||||
|
example2@example.com,김철수,XYZ회사`;
|
||||||
|
|
||||||
|
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "sample.csv";
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="mx-auto w-full space-y-6 px-6 py-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-card p-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-lg bg-primary/10 p-4">
|
||||||
|
<Users className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-1 text-3xl font-bold text-foreground">대량 메일 발송</h1>
|
||||||
|
<p className="text-muted-foreground">CSV 파일로 여러 수신자에게 메일을 발송하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/mail/dashboard">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
대시보드로
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 왼쪽: 설정 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 계정 선택 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">발송 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="account">발송 계정</Label>
|
||||||
|
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
|
||||||
|
<SelectTrigger id="account">
|
||||||
|
<SelectValue placeholder="계정 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={account.id}>
|
||||||
|
{account.name} ({account.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="mode">발송 방식</Label>
|
||||||
|
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}>
|
||||||
|
<SelectTrigger id="mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="template">템플릿 사용</SelectItem>
|
||||||
|
<SelectItem value="custom">직접 작성</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useTemplate ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="template">템플릿</Label>
|
||||||
|
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
||||||
|
<SelectTrigger id="template">
|
||||||
|
<SelectValue placeholder="템플릿 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<SelectItem key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customHtml">메일 내용</Label>
|
||||||
|
<Textarea
|
||||||
|
id="customHtml"
|
||||||
|
value={customHtml}
|
||||||
|
onChange={(e) => setCustomHtml(e.target.value)}
|
||||||
|
placeholder="메일 내용을 작성하세요..."
|
||||||
|
rows={10}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
{/* <p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
HTML 태그를 사용할 수 있습니다
|
||||||
|
</p> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="메일 제목을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CSV 업로드 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">수신자 업로드</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="csv">CSV 파일</Label>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="csv"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={downloadSampleCsv}
|
||||||
|
title="샘플 다운로드"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
첫 번째 줄은 헤더(email, name, company 등)여야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{csvFile && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border bg-muted p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{csvFile.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCsvFile(null);
|
||||||
|
setRecipients([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="rounded-md border bg-muted p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="font-medium">{recipients.length}명의 수신자</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
변수: {Object.keys(recipients[0]?.variables || {}).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 미리보기 & 발송 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 발송 버튼 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">발송 실행</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{sending && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>발송 진행 중...</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{sendProgress.sent} / {sendProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || recipients.length === 0}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
발송 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-5 w-5" />
|
||||||
|
{recipients.length}명에게 발송
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-muted p-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium">주의사항</p>
|
||||||
|
<ul className="mt-1 list-inside list-disc space-y-1">
|
||||||
|
<li>발송 속도는 계정 설정에 따라 제한됩니다</li>
|
||||||
|
<li>대량 발송 시 스팸으로 분류될 수 있습니다</li>
|
||||||
|
<li>발송 후 취소할 수 없습니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 수신자 목록 미리보기 */}
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">수신자 목록 미리보기</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
|
{recipients.slice(0, 10).map((recipient, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-md border bg-muted p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{recipient.email}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{Object.entries(recipient.variables).map(([key, value]) => (
|
||||||
|
<span key={key} className="mr-2">
|
||||||
|
{key}: {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{recipients.length > 10 && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
외 {recipients.length - 10}명
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,9 +13,12 @@ import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
Calendar,
|
||||||
ArrowRight
|
ArrowRight,
|
||||||
|
Trash2,
|
||||||
|
Edit
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
||||||
|
import MailNotifications from "@/components/mail/MailNotifications";
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalAccounts: number;
|
totalAccounts: number;
|
||||||
|
|
@ -42,14 +45,34 @@ export default function MailDashboardPage() {
|
||||||
try {
|
try {
|
||||||
const accounts = await getMailAccounts();
|
const accounts = await getMailAccounts();
|
||||||
const templates = await getMailTemplates();
|
const templates = await getMailTemplates();
|
||||||
const mailStats = await getMailStatistics();
|
|
||||||
|
// 메일 통계 조회 (실패 시 기본값 사용)
|
||||||
|
let mailStats = {
|
||||||
|
todayCount: 0,
|
||||||
|
thisMonthCount: 0,
|
||||||
|
successRate: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await getMailStatistics();
|
||||||
|
if (stats && typeof stats === 'object') {
|
||||||
|
mailStats = {
|
||||||
|
todayCount: stats.todayCount || 0,
|
||||||
|
thisMonthCount: stats.thisMonthCount || 0,
|
||||||
|
successRate: stats.successRate || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('메일 통계 조회 실패:', error);
|
||||||
|
// 기본값 사용
|
||||||
|
}
|
||||||
|
|
||||||
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
||||||
let receivedTodayCount = 0;
|
let receivedTodayCount = 0;
|
||||||
try {
|
try {
|
||||||
receivedTodayCount = await getTodayReceivedCount();
|
receivedTodayCount = await getTodayReceivedCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('수신 메일 수 조회 실패:', error);
|
// console.error('수신 메일 수 조회 실패:', error);
|
||||||
// 실패 시 0으로 표시
|
// 실패 시 0으로 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +156,13 @@ export default function MailDashboardPage() {
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "대량 발송",
|
||||||
|
description: "CSV로 대량 발송",
|
||||||
|
href: "/admin/mail/bulk-send",
|
||||||
|
icon: Users,
|
||||||
|
color: "teal",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "보낸메일함",
|
title: "보낸메일함",
|
||||||
description: "발송 이력 확인",
|
description: "발송 이력 확인",
|
||||||
|
|
@ -147,11 +177,25 @@ export default function MailDashboardPage() {
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "purple",
|
color: "purple",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "임시 저장",
|
||||||
|
description: "작성 중인 메일",
|
||||||
|
href: "/admin/mail/drafts",
|
||||||
|
icon: Edit,
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "휴지통",
|
||||||
|
description: "삭제된 메일",
|
||||||
|
href: "/admin/mail/trash",
|
||||||
|
icon: Trash2,
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
|
<div className="w-full px-3 py-3 space-y-3">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -163,6 +207,8 @@ export default function MailDashboardPage() {
|
||||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<MailNotifications />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -173,9 +219,10 @@ export default function MailDashboardPage() {
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{statCards.map((stat, index) => (
|
{statCards.map((stat, index) => (
|
||||||
<Link key={index} href={stat.href}>
|
<Link key={index} href={stat.href}>
|
||||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
||||||
|
|
@ -207,7 +254,7 @@ export default function MailDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이번 달 통계 */}
|
{/* 이번 달 통계 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="text-lg flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Edit, Trash2, Loader2, Mail } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
export default function DraftsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [drafts, setDrafts] = useState<SentMailHistory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDrafts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDrafts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getSentMailList({
|
||||||
|
status: "draft",
|
||||||
|
sortBy: "updatedAt",
|
||||||
|
sortOrder: "desc",
|
||||||
|
});
|
||||||
|
// console.log('📋 임시 저장 목록 조회:', response);
|
||||||
|
// console.log('📋 임시 저장 개수:', response.items.length);
|
||||||
|
setDrafts(response.items);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("❌ 임시 저장 메일 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (draft: SentMailHistory) => {
|
||||||
|
// 임시 저장 메일을 메일 발송 페이지로 전달
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
draftId: draft.id,
|
||||||
|
to: draft.to.join(","),
|
||||||
|
cc: draft.cc?.join(",") || "",
|
||||||
|
bcc: draft.bcc?.join(",") || "",
|
||||||
|
subject: draft.subject,
|
||||||
|
content: draft.htmlContent,
|
||||||
|
accountId: draft.accountId,
|
||||||
|
});
|
||||||
|
router.push(`/admin/mail/send?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("이 임시 저장 메일을 삭제하시겠습니까?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(id);
|
||||||
|
await deleteSentMail(id);
|
||||||
|
setDrafts(drafts.filter((d) => d.id !== id));
|
||||||
|
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("임시 저장 메일 삭제 실패:", error);
|
||||||
|
alert("삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
alert("삭제할 메일을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`선택한 ${selectedIds.length}개의 임시 저장 메일을 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBulkDeleting(true);
|
||||||
|
const result = await bulkDeleteMails(selectedIds);
|
||||||
|
setDrafts(drafts.filter((d) => !selectedIds.includes(d.id)));
|
||||||
|
setSelectedIds([]);
|
||||||
|
alert(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("일괄 삭제 실패:", error);
|
||||||
|
alert("일괄 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedIds.length === drafts.length) {
|
||||||
|
setSelectedIds([]);
|
||||||
|
} else {
|
||||||
|
setSelectedIds(drafts.map((d) => d.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectOne = (id: string) => {
|
||||||
|
if (selectedIds.includes(id)) {
|
||||||
|
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
||||||
|
} else {
|
||||||
|
setSelectedIds([...selectedIds, id]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">임시보관함</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">작성 중인 메일이 자동으로 저장됩니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drafts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">임시 저장된 메일이 없습니다</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{drafts.map((draft) => (
|
||||||
|
<Card key={draft.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-lg truncate">
|
||||||
|
{draft.subject || "(제목 없음)"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
받는 사람: {draft.to.join(", ") || "(없음)"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(draft)}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(draft.id)}
|
||||||
|
disabled={deleting === draft.id}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{deleting === draft.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>계정: {draft.accountName || draft.accountEmail}</span>
|
||||||
|
<span>
|
||||||
|
{draft.updatedAt
|
||||||
|
? format(new Date(draft.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
|
||||||
|
: format(new Date(draft.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{draft.htmlContent && (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-sm text-muted-foreground line-clamp-2"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -16,21 +16,30 @@ import {
|
||||||
SortAsc,
|
SortAsc,
|
||||||
SortDesc,
|
SortDesc,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Reply,
|
||||||
|
Forward,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
MailAccount,
|
MailAccount,
|
||||||
ReceivedMail,
|
ReceivedMail,
|
||||||
|
MailDetail,
|
||||||
getMailAccounts,
|
getMailAccounts,
|
||||||
getReceivedMails,
|
getReceivedMails,
|
||||||
testImapConnection,
|
testImapConnection,
|
||||||
|
getMailDetail,
|
||||||
|
markMailAsRead,
|
||||||
|
downloadMailAttachment,
|
||||||
} from "@/lib/api/mail";
|
} from "@/lib/api/mail";
|
||||||
import MailDetailModal from "@/components/mail/MailDetailModal";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
|
||||||
export default function MailReceivePage() {
|
export default function MailReceivePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||||
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
||||||
|
|
@ -41,15 +50,23 @@ export default function MailReceivePage() {
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 메일 상세 모달 상태
|
// 메일 상세 상태 (모달 대신 패널)
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
|
||||||
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
||||||
|
const [selectedMailDetail, setSelectedMailDetail] = useState<MailDetail | null>(null);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// 검색 및 필터 상태
|
// 검색 및 필터 상태
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
|
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
|
||||||
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
|
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage] = useState(10);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [allMails, setAllMails] = useState<ReceivedMail[]>([]); // 전체 메일 저장
|
||||||
|
|
||||||
// 계정 목록 로드
|
// 계정 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
|
@ -58,10 +75,42 @@ export default function MailReceivePage() {
|
||||||
// 계정 선택 시 메일 로드
|
// 계정 선택 시 메일 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAccountId) {
|
if (selectedAccountId) {
|
||||||
|
setCurrentPage(1); // 계정 변경 시 첫 페이지로
|
||||||
loadMails();
|
loadMails();
|
||||||
}
|
}
|
||||||
}, [selectedAccountId]);
|
}, [selectedAccountId]);
|
||||||
|
|
||||||
|
// 페이지 변경 시 페이지네이션 재적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (allMails.length > 0) {
|
||||||
|
applyPagination(allMails);
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
// URL 파라미터에서 mailId 읽기 및 자동 선택
|
||||||
|
useEffect(() => {
|
||||||
|
const mailId = searchParams.get('mailId');
|
||||||
|
const accountId = searchParams.get('accountId');
|
||||||
|
|
||||||
|
if (mailId && accountId) {
|
||||||
|
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
|
||||||
|
setSelectedAccountId(accountId);
|
||||||
|
setSelectedMailId(mailId);
|
||||||
|
// 메일 상세 로드는 handleMailClick에서 처리됨
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
|
||||||
|
const mail = mails.find(m => m.id === selectedMailId);
|
||||||
|
if (mail) {
|
||||||
|
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
|
||||||
|
handleMailClick(mail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지
|
||||||
|
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (30초마다)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAccountId) return;
|
if (!selectedAccountId) return;
|
||||||
|
|
@ -84,7 +133,7 @@ export default function MailReceivePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("계정 로드 실패:", error);
|
// console.error("계정 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,21 +143,47 @@ export default function MailReceivePage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
try {
|
||||||
const data = await getReceivedMails(selectedAccountId, 50);
|
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
|
||||||
setMails(data);
|
|
||||||
|
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
|
||||||
|
const processedMails = data.map(mail => ({
|
||||||
|
...mail,
|
||||||
|
isRead: mail.isRead
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAllMails(processedMails); // 전체 메일 저장
|
||||||
|
|
||||||
|
// 페이지네이션 적용
|
||||||
|
applyPagination(processedMails);
|
||||||
|
|
||||||
|
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
|
||||||
|
window.dispatchEvent(new CustomEvent('mail-received'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메일 로드 실패:", error);
|
// console.error("메일 로드 실패:", error);
|
||||||
alert(
|
alert(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "메일을 불러오는데 실패했습니다."
|
: "메일을 불러오는데 실패했습니다."
|
||||||
);
|
);
|
||||||
setMails([]);
|
setMails([]);
|
||||||
|
setAllMails([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyPagination = (mailList: ReceivedMail[]) => {
|
||||||
|
const totalItems = mailList.length;
|
||||||
|
const totalPagesCalc = Math.ceil(totalItems / itemsPerPage);
|
||||||
|
setTotalPages(totalPagesCalc);
|
||||||
|
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = startIndex + itemsPerPage;
|
||||||
|
const paginatedMails = mailList.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
setMails(paginatedMails);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
if (!selectedAccountId) return;
|
if (!selectedAccountId) return;
|
||||||
|
|
||||||
|
|
@ -153,14 +228,94 @@ export default function MailReceivePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMailClick = (mail: ReceivedMail) => {
|
const handleMailClick = async (mail: ReceivedMail) => {
|
||||||
setSelectedMailId(mail.id);
|
setSelectedMailId(mail.id);
|
||||||
setIsDetailModalOpen(true);
|
setLoadingDetail(true);
|
||||||
|
|
||||||
|
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
|
||||||
|
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
|
||||||
|
setMails((prevMails) =>
|
||||||
|
prevMails.map((m) =>
|
||||||
|
m.id === mail.id ? { ...m, isRead: true } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 메일 상세 정보 로드
|
||||||
|
try {
|
||||||
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
||||||
|
const mailIdParts = mail.id.split('-');
|
||||||
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
||||||
|
const seqno = parseInt(mailIdParts[2], 10); // 13
|
||||||
|
|
||||||
|
// console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id);
|
||||||
|
|
||||||
|
const detail = await getMailDetail(accountId, seqno);
|
||||||
|
setSelectedMailDetail(detail);
|
||||||
|
|
||||||
|
// 읽음 처리
|
||||||
|
if (!mail.isRead) {
|
||||||
|
await markMailAsRead(accountId, seqno);
|
||||||
|
// console.log('✅ 읽음 처리 완료 - seqno:', seqno);
|
||||||
|
|
||||||
|
// 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기
|
||||||
|
setTimeout(() => {
|
||||||
|
if (selectedAccountId) {
|
||||||
|
// console.log('🔄 서버 상태 동기화 시작');
|
||||||
|
loadMails();
|
||||||
|
}
|
||||||
|
}, 2000); // 2초로 증가
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('메일 상세 로드 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDetail(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMailRead = () => {
|
const handleDeleteMail = async () => {
|
||||||
// 메일을 읽었으므로 목록 새로고침
|
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
|
||||||
loadMails();
|
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
|
||||||
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
||||||
|
const mailIdParts = selectedMailId.split('-');
|
||||||
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
||||||
|
const seqno = parseInt(mailIdParts[2], 10); // 10
|
||||||
|
|
||||||
|
// console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`);
|
||||||
|
|
||||||
|
// IMAP 서버에서 메일 삭제 (타임아웃 40초)
|
||||||
|
const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, {
|
||||||
|
timeout: 40000, // 40초 타임아웃
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
// 메일 목록에서 제거
|
||||||
|
setMails(mails.filter((m) => m.id !== selectedMailId));
|
||||||
|
|
||||||
|
// 상세 패널 닫기
|
||||||
|
setSelectedMailId("");
|
||||||
|
setSelectedMailDetail(null);
|
||||||
|
|
||||||
|
alert("메일이 삭제되었습니다.");
|
||||||
|
// console.log("✅ 메일 삭제 완료");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// console.error("메일 삭제 실패:", error);
|
||||||
|
|
||||||
|
let errorMessage = "메일 삭제에 실패했습니다.";
|
||||||
|
|
||||||
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||||
|
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
|
||||||
|
} else if (error.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터링 및 정렬된 메일 목록
|
// 필터링 및 정렬된 메일 목록
|
||||||
|
|
@ -365,7 +520,10 @@ export default function MailReceivePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 메일 목록 */}
|
{/* 네이버 메일 스타일 3-column 레이아웃 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* 왼쪽: 메일 목록 */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex justify-center items-center py-16">
|
||||||
|
|
@ -409,14 +567,14 @@ export default function MailReceivePage() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||||
{filteredAndSortedMails.map((mail) => (
|
{filteredAndSortedMails.map((mail) => (
|
||||||
<div
|
<div
|
||||||
key={mail.id}
|
key={mail.id}
|
||||||
onClick={() => handleMailClick(mail)}
|
onClick={() => handleMailClick(mail)}
|
||||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
||||||
!mail.isRead ? "bg-blue-50/30" : ""
|
!mail.isRead ? "bg-blue-50/30" : ""
|
||||||
}`}
|
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* 읽음 표시 */}
|
{/* 읽음 표시 */}
|
||||||
|
|
@ -463,8 +621,283 @@ export default function MailReceivePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
처음
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
마지막
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 메일 상세 패널 */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{selectedMailDetail ? (
|
||||||
|
<Card className="sticky top-6">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">{selectedMailDetail.subject}</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMailId("");
|
||||||
|
setSelectedMailDetail(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1 mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">보낸 사람:</span>
|
||||||
|
<span>{selectedMailDetail.from}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">받는 사람:</span>
|
||||||
|
<span>{selectedMailDetail.to}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">날짜:</span>
|
||||||
|
<span>{new Date(selectedMailDetail.date).toLocaleString("ko-KR")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 답장/전달/삭제 버튼 */}
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// HTML 태그 제거 함수 (강력한 버전)
|
||||||
|
const stripHtml = (html: string) => {
|
||||||
|
if (!html) return "";
|
||||||
|
|
||||||
|
// 1. DOMPurify로 먼저 정제
|
||||||
|
const cleanHtml = DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
||||||
|
KEEP_CONTENT: true // 내용만 유지
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. DOM으로 텍스트만 추출
|
||||||
|
const tmp = document.createElement("DIV");
|
||||||
|
tmp.innerHTML = cleanHtml;
|
||||||
|
let text = tmp.textContent || tmp.innerText || "";
|
||||||
|
|
||||||
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
||||||
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
||||||
|
|
||||||
|
// 4. 연속된 공백 정리
|
||||||
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log('📧 답장 데이터:', {
|
||||||
|
// htmlBody: selectedMailDetail.htmlBody,
|
||||||
|
// textBody: selectedMailDetail.textBody,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
||||||
|
const bodyText = selectedMailDetail.textBody
|
||||||
|
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
||||||
|
|
||||||
|
// console.log('📧 변환된 본문:', bodyText);
|
||||||
|
|
||||||
|
const replyData = {
|
||||||
|
originalFrom: selectedMailDetail.from,
|
||||||
|
originalSubject: selectedMailDetail.subject,
|
||||||
|
originalDate: selectedMailDetail.date,
|
||||||
|
originalBody: bodyText,
|
||||||
|
};
|
||||||
|
router.push(
|
||||||
|
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Reply className="w-4 h-4 mr-1" />
|
||||||
|
답장
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// HTML 태그 제거 함수 (강력한 버전)
|
||||||
|
const stripHtml = (html: string) => {
|
||||||
|
if (!html) return "";
|
||||||
|
|
||||||
|
// 1. DOMPurify로 먼저 정제
|
||||||
|
const cleanHtml = DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
||||||
|
KEEP_CONTENT: true // 내용만 유지
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. DOM으로 텍스트만 추출
|
||||||
|
const tmp = document.createElement("DIV");
|
||||||
|
tmp.innerHTML = cleanHtml;
|
||||||
|
let text = tmp.textContent || tmp.innerText || "";
|
||||||
|
|
||||||
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
||||||
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
||||||
|
|
||||||
|
// 4. 연속된 공백 정리
|
||||||
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log('📧 전달 데이터:', {
|
||||||
|
// htmlBody: selectedMailDetail.htmlBody,
|
||||||
|
// textBody: selectedMailDetail.textBody,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
||||||
|
const bodyText = selectedMailDetail.textBody
|
||||||
|
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
||||||
|
|
||||||
|
// console.log('📧 변환된 본문:', bodyText);
|
||||||
|
|
||||||
|
const forwardData = {
|
||||||
|
originalFrom: selectedMailDetail.from,
|
||||||
|
originalSubject: selectedMailDetail.subject,
|
||||||
|
originalDate: selectedMailDetail.date,
|
||||||
|
originalBody: bodyText,
|
||||||
|
};
|
||||||
|
router.push(
|
||||||
|
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Forward className="w-4 h-4 mr-1" />
|
||||||
|
전달
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteMail}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
)}
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||||
|
{/* 첨부파일 */}
|
||||||
|
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
||||||
|
<div className="mb-4 p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm font-medium mb-2">첨부파일 ({selectedMailDetail.attachments.length}개)</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedMailDetail.attachments.map((att, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<Paperclip className="w-4 h-4" />
|
||||||
|
<span>{att.filename}</span>
|
||||||
|
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메일 본문 */}
|
||||||
|
{selectedMailDetail.htmlBody ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(selectedMailDetail.htmlBody),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="whitespace-pre-wrap text-sm">
|
||||||
|
{selectedMailDetail.textBody}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : loadingDetail ? (
|
||||||
|
<Card className="sticky top-6">
|
||||||
|
<CardContent className="flex justify-center items-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||||
|
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="sticky top-6">
|
||||||
|
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
||||||
|
<Mail className="w-16 h-16 mb-4 text-gray-300" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
메일을 선택하면 내용이 표시됩니다
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 안내 정보 */}
|
{/* 안내 정보 */}
|
||||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
||||||
|
|
@ -563,15 +996,6 @@ export default function MailReceivePage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메일 상세 모달 */}
|
|
||||||
<MailDetailModal
|
|
||||||
isOpen={isDetailModalOpen}
|
|
||||||
onClose={() => setIsDetailModalOpen(false)}
|
|
||||||
accountId={selectedAccountId}
|
|
||||||
mailId={selectedMailId}
|
|
||||||
onMailRead={handleMailRead}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,11 +42,14 @@ import {
|
||||||
sendMail,
|
sendMail,
|
||||||
extractTemplateVariables,
|
extractTemplateVariables,
|
||||||
renderTemplateToHtml,
|
renderTemplateToHtml,
|
||||||
|
saveDraft,
|
||||||
|
updateDraft,
|
||||||
} from "@/lib/api/mail";
|
} from "@/lib/api/mail";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function MailSendPage() {
|
export default function MailSendPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
|
|
@ -66,6 +69,7 @@ export default function MailSendPage() {
|
||||||
const [customHtml, setCustomHtml] = useState<string>("");
|
const [customHtml, setCustomHtml] = useState<string>("");
|
||||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드
|
||||||
|
|
||||||
// 템플릿 변수
|
// 템플릿 변수
|
||||||
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
||||||
|
|
@ -74,9 +78,113 @@ export default function MailSendPage() {
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
const [attachments, setAttachments] = useState<File[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
// 임시 저장
|
||||||
|
const [draftId, setDraftId] = useState<string | null>(null);
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [autoSaving, setAutoSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
|
||||||
|
// 답장/전달 데이터 처리
|
||||||
|
const action = searchParams.get("action");
|
||||||
|
const dataParam = searchParams.get("data");
|
||||||
|
|
||||||
|
if (action && dataParam) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(decodeURIComponent(dataParam));
|
||||||
|
|
||||||
|
if (action === "reply") {
|
||||||
|
// 답장: 받는사람 자동 입력, 제목에 Re: 추가
|
||||||
|
const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom;
|
||||||
|
setTo([fromEmail]);
|
||||||
|
setSubject(data.originalSubject.startsWith("Re: ")
|
||||||
|
? data.originalSubject
|
||||||
|
: `Re: ${data.originalSubject}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
||||||
|
const originalMessage = `
|
||||||
|
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
원본 메일:
|
||||||
|
|
||||||
|
보낸사람: ${data.originalFrom}
|
||||||
|
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
||||||
|
제목: ${data.originalSubject}
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
${data.originalBody}`;
|
||||||
|
|
||||||
|
setCustomHtml(originalMessage);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '답장 작성',
|
||||||
|
description: '받는사람과 제목이 자동으로 입력되었습니다.',
|
||||||
|
});
|
||||||
|
} else if (action === "forward") {
|
||||||
|
// 전달: 받는사람 비어있음, 제목에 Fwd: 추가
|
||||||
|
setSubject(data.originalSubject.startsWith("Fwd: ")
|
||||||
|
? data.originalSubject
|
||||||
|
: `Fwd: ${data.originalSubject}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
||||||
|
const originalMessage = `
|
||||||
|
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
전달된 메일:
|
||||||
|
|
||||||
|
보낸사람: ${data.originalFrom}
|
||||||
|
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
||||||
|
제목: ${data.originalSubject}
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
${data.originalBody}`;
|
||||||
|
|
||||||
|
setCustomHtml(originalMessage);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '메일 전달',
|
||||||
|
description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL에서 파라미터 제거 (깔끔하게)
|
||||||
|
router.replace("/admin/mail/send");
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("답장/전달 데이터 파싱 실패:", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 저장 메일 불러오기
|
||||||
|
const draftIdParam = searchParams.get("draftId");
|
||||||
|
const toParam = searchParams.get("to");
|
||||||
|
const ccParam = searchParams.get("cc");
|
||||||
|
const bccParam = searchParams.get("bcc");
|
||||||
|
const subjectParam = searchParams.get("subject");
|
||||||
|
const contentParam = searchParams.get("content");
|
||||||
|
const accountIdParam = searchParams.get("accountId");
|
||||||
|
|
||||||
|
if (draftIdParam) {
|
||||||
|
setDraftId(draftIdParam);
|
||||||
|
if (toParam) setTo(toParam.split(",").filter(Boolean));
|
||||||
|
if (ccParam) setCc(ccParam.split(",").filter(Boolean));
|
||||||
|
if (bccParam) setBcc(bccParam.split(",").filter(Boolean));
|
||||||
|
if (subjectParam) setSubject(subjectParam);
|
||||||
|
if (contentParam) setCustomHtml(contentParam);
|
||||||
|
if (accountIdParam) setSelectedAccountId(accountIdParam);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '임시 저장 메일 불러오기',
|
||||||
|
description: '작성 중이던 메일을 불러왔습니다.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,20 +193,28 @@ export default function MailSendPage() {
|
||||||
getMailAccounts(),
|
getMailAccounts(),
|
||||||
getMailTemplates(),
|
getMailTemplates(),
|
||||||
]);
|
]);
|
||||||
setAccounts(accountsData.filter((acc) => acc.status === "active"));
|
const activeAccounts = accountsData.filter((acc) => acc.status === "active");
|
||||||
|
setAccounts(activeAccounts);
|
||||||
setTemplates(templatesData);
|
setTemplates(templatesData);
|
||||||
console.log('📦 데이터 로드 완료:', {
|
|
||||||
accounts: accountsData.length,
|
// 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택
|
||||||
templates: templatesData.length,
|
if (!selectedAccountId && activeAccounts.length > 0) {
|
||||||
templatesDetail: templatesData.map(t => ({
|
setSelectedAccountId(activeAccounts[0].id);
|
||||||
id: t.id,
|
// console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email);
|
||||||
name: t.name,
|
}
|
||||||
componentsCount: t.components?.length || 0
|
|
||||||
}))
|
// console.log('📦 데이터 로드 완료:', {
|
||||||
});
|
// accounts: accountsData.length,
|
||||||
|
// templates: templatesData.length,
|
||||||
|
// templatesDetail: templatesData.map(t => ({
|
||||||
|
// id: t.id,
|
||||||
|
// name: t.name,
|
||||||
|
// componentsCount: t.components?.length || 0
|
||||||
|
// }))
|
||||||
|
// });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
console.error('❌ 데이터 로드 실패:', err);
|
// console.error('❌ 데이터 로드 실패:', err);
|
||||||
toast({
|
toast({
|
||||||
title: "데이터 로드 실패",
|
title: "데이터 로드 실패",
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
|
@ -109,13 +225,62 @@ export default function MailSendPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 임시 저장 함수
|
||||||
|
const handleAutoSave = async () => {
|
||||||
|
if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) {
|
||||||
|
return; // 저장할 내용이 없으면 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAutoSaving(true);
|
||||||
|
|
||||||
|
const draftData = {
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
accountName: accounts.find(a => a.id === selectedAccountId)?.name || "",
|
||||||
|
accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "",
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
subject,
|
||||||
|
htmlContent: customHtml,
|
||||||
|
templateId: selectedTemplateId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (draftId) {
|
||||||
|
// 기존 임시 저장 업데이트
|
||||||
|
await updateDraft(draftId, draftData);
|
||||||
|
} else {
|
||||||
|
// 새로운 임시 저장
|
||||||
|
const savedDraft = await saveDraft(draftData);
|
||||||
|
if (savedDraft && savedDraft.id) {
|
||||||
|
setDraftId(savedDraft.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastSaved(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('임시 저장 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setAutoSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 30초마다 자동 저장
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
handleAutoSave();
|
||||||
|
}, 30000); // 30초
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]);
|
||||||
|
|
||||||
// 템플릿 선택 시 (원본 다시 로드)
|
// 템플릿 선택 시 (원본 다시 로드)
|
||||||
const handleTemplateChange = async (templateId: string) => {
|
const handleTemplateChange = async (templateId: string) => {
|
||||||
console.log('🔄 템플릿 선택됨:', templateId);
|
console.log('🔄 템플릿 선택됨:', templateId);
|
||||||
|
|
||||||
// "__custom__"는 직접 작성을 의미
|
// "__custom__"는 직접 작성을 의미
|
||||||
if (templateId === "__custom__") {
|
if (templateId === "__custom__") {
|
||||||
console.log('✏️ 직접 작성 모드');
|
// console.log('✏️ 직접 작성 모드');
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateVariables([]);
|
setTemplateVariables([]);
|
||||||
setVariables({});
|
setVariables({});
|
||||||
|
|
@ -124,20 +289,20 @@ export default function MailSendPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
|
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
|
||||||
console.log('🔃 원본 템플릿 API에서 재로드 중...');
|
// console.log('🔃 원본 템플릿 API에서 재로드 중...');
|
||||||
const freshTemplates = await getMailTemplates();
|
const freshTemplates = await getMailTemplates();
|
||||||
const template = freshTemplates.find((t) => t.id === templateId);
|
const template = freshTemplates.find((t) => t.id === templateId);
|
||||||
|
|
||||||
console.log('📋 찾은 템플릿:', {
|
// console.log('📋 찾은 템플릿:', {
|
||||||
found: !!template,
|
// found: !!template,
|
||||||
templateId,
|
// templateId,
|
||||||
availableTemplates: freshTemplates.length,
|
// availableTemplates: freshTemplates.length,
|
||||||
template: template ? {
|
// template: template ? {
|
||||||
id: template.id,
|
// id: template.id,
|
||||||
name: template.name,
|
// name: template.name,
|
||||||
componentsCount: template.components?.length || 0
|
// componentsCount: template.components?.length || 0
|
||||||
} : null
|
// } : null
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (template) {
|
if (template) {
|
||||||
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
|
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
|
||||||
|
|
@ -153,18 +318,18 @@ export default function MailSendPage() {
|
||||||
});
|
});
|
||||||
setVariables(initialVars);
|
setVariables(initialVars);
|
||||||
|
|
||||||
console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
|
// console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
|
||||||
subject: template.subject,
|
// subject: template.subject,
|
||||||
variables: vars
|
// variables: vars
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateVariables([]);
|
setTemplateVariables([]);
|
||||||
setVariables({});
|
setVariables({});
|
||||||
console.warn('⚠️ 템플릿을 찾을 수 없음');
|
// console.warn('⚠️ 템플릿을 찾을 수 없음');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 템플릿 재로드 실패:', error);
|
// console.error('❌ 템플릿 재로드 실패:', error);
|
||||||
toast({
|
toast({
|
||||||
title: "템플릿 로드 실패",
|
title: "템플릿 로드 실패",
|
||||||
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
|
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
|
||||||
|
|
@ -228,7 +393,7 @@ export default function MailSendPage() {
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
|
||||||
${html}
|
${html}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -275,8 +440,12 @@ export default function MailSendPage() {
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
// 텍스트를 HTML로 자동 변환
|
// HTML 변환
|
||||||
const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined;
|
let htmlContent = undefined;
|
||||||
|
if (customHtml.trim()) {
|
||||||
|
// 일반 텍스트를 HTML로 변환
|
||||||
|
htmlContent = convertTextToHtml(customHtml);
|
||||||
|
}
|
||||||
|
|
||||||
// FormData 생성 (파일 첨부 지원)
|
// FormData 생성 (파일 첨부 지원)
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
@ -288,7 +457,7 @@ export default function MailSendPage() {
|
||||||
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
|
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||||
if (currentTemplate) {
|
if (currentTemplate) {
|
||||||
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
|
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
|
||||||
console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
|
// console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
formData.append("to", JSON.stringify(to));
|
formData.append("to", JSON.stringify(to));
|
||||||
|
|
@ -316,11 +485,11 @@ export default function MailSendPage() {
|
||||||
const originalFileNames = attachments.map(file => {
|
const originalFileNames = attachments.map(file => {
|
||||||
// 파일명 정규화 (NFD → NFC)
|
// 파일명 정규화 (NFD → NFC)
|
||||||
const normalizedName = file.name.normalize('NFC');
|
const normalizedName = file.name.normalize('NFC');
|
||||||
console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
|
// console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
|
||||||
return normalizedName;
|
return normalizedName;
|
||||||
});
|
});
|
||||||
formData.append("fileNames", JSON.stringify(originalFileNames));
|
formData.append("fileNames", JSON.stringify(originalFileNames));
|
||||||
console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
|
// console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 호출 (FormData 전송)
|
// API 호출 (FormData 전송)
|
||||||
|
|
@ -354,6 +523,9 @@ export default function MailSendPage() {
|
||||||
className: "border-green-500 bg-green-50",
|
className: "border-green-500 bg-green-50",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 알림 갱신 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent('mail-sent'));
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
setTo([]);
|
setTo([]);
|
||||||
setCc([]);
|
setCc([]);
|
||||||
|
|
@ -383,6 +555,58 @@ export default function MailSendPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 임시 저장
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
try {
|
||||||
|
setAutoSaving(true);
|
||||||
|
|
||||||
|
const account = accounts.find(a => a.id === selectedAccountId);
|
||||||
|
const draftData = {
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
accountName: account?.name || "",
|
||||||
|
accountEmail: account?.email || "",
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
subject,
|
||||||
|
htmlContent: customHtml,
|
||||||
|
templateId: selectedTemplateId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log('💾 임시 저장 데이터:', draftData);
|
||||||
|
|
||||||
|
if (draftId) {
|
||||||
|
// 기존 임시 저장 업데이트
|
||||||
|
await updateDraft(draftId, draftData);
|
||||||
|
// console.log('✏️ 임시 저장 업데이트 완료:', draftId);
|
||||||
|
} else {
|
||||||
|
// 새로운 임시 저장
|
||||||
|
const savedDraft = await saveDraft(draftData);
|
||||||
|
// console.log('💾 임시 저장 완료:', savedDraft);
|
||||||
|
if (savedDraft && savedDraft.id) {
|
||||||
|
setDraftId(savedDraft.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastSaved(new Date());
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "임시 저장 완료",
|
||||||
|
description: "작성 중인 메일이 저장되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
// console.error('❌ 임시 저장 실패:', err);
|
||||||
|
toast({
|
||||||
|
title: "임시 저장 실패",
|
||||||
|
description: err.message || "임시 저장 중 오류가 발생했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAutoSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 파일 첨부 관련 함수
|
// 파일 첨부 관련 함수
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
@ -477,15 +701,15 @@ export default function MailSendPage() {
|
||||||
if (selectedTemplateId) {
|
if (selectedTemplateId) {
|
||||||
const template = templates.find((t) => t.id === selectedTemplateId);
|
const template = templates.find((t) => t.id === selectedTemplateId);
|
||||||
if (template) {
|
if (template) {
|
||||||
console.log('🎨 템플릿 미리보기:', {
|
// console.log('🎨 템플릿 미리보기:', {
|
||||||
templateId: selectedTemplateId,
|
// templateId: selectedTemplateId,
|
||||||
templateName: template.name,
|
// templateName: template.name,
|
||||||
componentsCount: template.components?.length || 0,
|
// componentsCount: template.components?.length || 0,
|
||||||
components: template.components,
|
// components: template.components,
|
||||||
variables
|
// variables
|
||||||
});
|
// });
|
||||||
const html = renderTemplateToHtml(template, variables);
|
const html = renderTemplateToHtml(template, variables);
|
||||||
console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
|
// console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
|
||||||
|
|
||||||
// 추가 메시지가 있으면 병합
|
// 추가 메시지가 있으면 병합
|
||||||
if (customHtml && customHtml.trim()) {
|
if (customHtml && customHtml.trim()) {
|
||||||
|
|
@ -531,9 +755,72 @@ export default function MailSendPage() {
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">메일 발송</h1>
|
<h1 className="text-3xl font-bold text-foreground">
|
||||||
<p className="mt-2 text-muted-foreground">템플릿을 선택하거나 직접 작성하여 메일을 발송하세요</p>
|
{subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{subject.startsWith("Re: ")
|
||||||
|
? "받은 메일에 답장을 작성합니다"
|
||||||
|
: subject.startsWith("Fwd: ")
|
||||||
|
? "메일을 다른 사람에게 전달합니다"
|
||||||
|
: "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 임시 저장 표시 */}
|
||||||
|
{lastSaved && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
{autoSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>저장 중...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
<span>
|
||||||
|
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})} 임시 저장됨
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 임시 저장 버튼 */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={autoSaving}
|
||||||
|
>
|
||||||
|
{autoSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileText className="w-4 h-4 mr-1" />
|
||||||
|
임시 저장
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 임시 저장 목록 버튼 */}
|
||||||
|
<Link href="/admin/mail/drafts">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Mail className="w-4 h-4 mr-1" />
|
||||||
|
임시 저장 목록
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -957,30 +1244,41 @@ export default function MailSendPage() {
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 메일 내용 입력 - 항상 표시 */}
|
{/* 메일 내용 입력 */}
|
||||||
|
{!showPreview && !selectedTemplateId && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="customHtml">
|
<Label htmlFor="customHtml">내용 *</Label>
|
||||||
{selectedTemplateId ? "추가 메시지 (선택)" : "내용 *"}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="customHtml"
|
id="customHtml"
|
||||||
value={customHtml}
|
value={customHtml}
|
||||||
onChange={(e) => setCustomHtml(e.target.value)}
|
onChange={(e) => setCustomHtml(e.target.value)}
|
||||||
placeholder={
|
placeholder="메일 내용을 입력하세요 줄바꿈은 자동으로 처리됩니다."
|
||||||
selectedTemplateId
|
rows={12}
|
||||||
? "템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
|
className="resize-none"
|
||||||
: "메일 내용을 입력하세요\n\n줄바꿈은 자동으로 처리됩니다."
|
|
||||||
}
|
|
||||||
rows={10}
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{selectedTemplateId ? (
|
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
||||||
<>💡 입력한 내용은 템플릿 하단에 추가됩니다</>
|
|
||||||
) : (
|
|
||||||
<>💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 템플릿 선택 시 추가 메시지 */}
|
||||||
|
{!showPreview && selectedTemplateId && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customHtml">추가 메시지 (선택)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="customHtml"
|
||||||
|
value={customHtml}
|
||||||
|
onChange={(e) => setCustomHtml(e.target.value)}
|
||||||
|
placeholder="템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
|
||||||
|
rows={6}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
💡 입력한 내용은 템플릿 하단에 추가됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -1100,6 +1398,15 @@ export default function MailSendPage() {
|
||||||
<Eye className="w-5 h-5" />
|
<Eye className="w-5 h-5" />
|
||||||
미리보기
|
미리보기
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditingHtml(!isEditingHtml)}
|
||||||
|
>
|
||||||
|
{isEditingHtml ? <Eye className="w-4 h-4 mr-1" /> : <Settings className="w-4 h-4 mr-1" />}
|
||||||
|
{isEditingHtml ? "미리보기" : "편집"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1107,6 +1414,7 @@ export default function MailSendPage() {
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -1143,7 +1451,17 @@ export default function MailSendPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isEditingHtml ? (
|
||||||
|
<Textarea
|
||||||
|
value={customHtml}
|
||||||
|
onChange={(e) => setCustomHtml(e.target.value)}
|
||||||
|
rows={20}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
placeholder="HTML 코드를 직접 편집할 수 있습니다"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
|
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -42,7 +42,7 @@ export default function MailTemplatesPage() {
|
||||||
const data = await getMailTemplates();
|
const data = await getMailTemplates();
|
||||||
setTemplates(data);
|
setTemplates(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('템플릿 로드 실패:', error);
|
// console.error('템플릿 로드 실패:', error);
|
||||||
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -110,7 +110,7 @@ export default function MailTemplatesPage() {
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 삭제되었습니다.');
|
alert('템플릿이 삭제되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('템플릿 삭제 실패:', error);
|
// console.error('템플릿 삭제 실패:', error);
|
||||||
alert('템플릿 삭제에 실패했습니다.');
|
alert('템플릿 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -126,7 +126,7 @@ export default function MailTemplatesPage() {
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 복사되었습니다.');
|
alert('템플릿이 복사되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('템플릿 복사 실패:', error);
|
// console.error('템플릿 복사 실패:', error);
|
||||||
alert('템플릿 복사에 실패했습니다.');
|
alert('템플릿 복사에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getSentMailList, restoreMail, permanentlyDeleteMail, type SentMailHistory } from "@/lib/api/mail";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { RotateCcw, Trash2, Loader2, Mail, AlertCircle } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
export default function TrashPage() {
|
||||||
|
const [trashedMails, setTrashedMails] = useState<SentMailHistory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [restoring, setRestoring] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTrashedMails();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTrashedMails = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getSentMailList({
|
||||||
|
onlyDeleted: true,
|
||||||
|
sortBy: "sentAt",
|
||||||
|
sortOrder: "desc",
|
||||||
|
});
|
||||||
|
setTrashedMails(response.items);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("휴지통 메일 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setRestoring(id);
|
||||||
|
await restoreMail(id);
|
||||||
|
setTrashedMails(trashedMails.filter((m) => m.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("메일 복구 실패:", error);
|
||||||
|
alert("복구에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setRestoring(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermanentDelete = async (id: string) => {
|
||||||
|
if (!confirm("이 메일을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(id);
|
||||||
|
await permanentlyDeleteMail(id);
|
||||||
|
setTrashedMails(trashedMails.filter((m) => m.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("메일 영구 삭제 실패:", error);
|
||||||
|
alert("삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptyTrash = async () => {
|
||||||
|
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all(trashedMails.map((mail) => permanentlyDeleteMail(mail.id)));
|
||||||
|
setTrashedMails([]);
|
||||||
|
alert("휴지통을 비웠습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("휴지통 비우기 실패:", error);
|
||||||
|
alert("일부 메일 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">휴지통</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">삭제된 메일은 30일 후 자동으로 영구 삭제됩니다</p>
|
||||||
|
</div>
|
||||||
|
{trashedMails.length > 0 && (
|
||||||
|
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
휴지통 비우기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trashedMails.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">휴지통이 비어 있습니다</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{trashedMails.map((mail) => {
|
||||||
|
const deletedDate = mail.deletedAt ? new Date(mail.deletedAt) : null;
|
||||||
|
const daysLeft = deletedDate
|
||||||
|
? Math.max(0, 30 - Math.floor((Date.now() - deletedDate.getTime()) / (1000 * 60 * 60 * 24)))
|
||||||
|
: 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={mail.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-lg truncate">
|
||||||
|
{mail.subject || "(제목 없음)"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
받는 사람: {mail.to.join(", ") || "(없음)"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRestore(mail.id)}
|
||||||
|
disabled={restoring === mail.id}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{restoring === mail.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1" />
|
||||||
|
복구
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePermanentDelete(mail.id)}
|
||||||
|
disabled={deleting === mail.id}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{deleting === mail.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
영구 삭제
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
계정: {mail.accountName || mail.accountEmail}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{daysLeft <= 7 && (
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
<span>{daysLeft}일 후 자동 삭제됩니다</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MenuManagement } from "@/components/admin/MenuManagement";
|
import { MenuManagement } from "@/components/admin/MenuManagement";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
export default function MenuPage() {
|
export default function MenuPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">메뉴 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">메뉴 관리</h1>
|
<p className="text-sm text-muted-foreground">시스템 메뉴를 관리하고 화면을 할당합니다</p>
|
||||||
<p className="mt-2 text-gray-600">시스템 메뉴를 관리하고 화면을 할당합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
<MenuManagement />
|
<MenuManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react";
|
|
||||||
import ScreenList from "@/components/screen/ScreenList";
|
import ScreenList from "@/components/screen/ScreenList";
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
import TemplateManager from "@/components/screen/TemplateManager";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
// 단계별 진행을 위한 타입 정의
|
// 단계별 진행을 위한 타입 정의
|
||||||
|
|
@ -25,17 +25,14 @@ export default function ScreenManagementPage() {
|
||||||
list: {
|
list: {
|
||||||
title: "화면 목록 관리",
|
title: "화면 목록 관리",
|
||||||
description: "생성된 화면들을 확인하고 관리하세요",
|
description: "생성된 화면들을 확인하고 관리하세요",
|
||||||
icon: "📋",
|
|
||||||
},
|
},
|
||||||
design: {
|
design: {
|
||||||
title: "화면 설계",
|
title: "화면 설계",
|
||||||
description: "드래그앤드롭으로 화면을 설계하세요",
|
description: "드래그앤드롭으로 화면을 설계하세요",
|
||||||
icon: "🎨",
|
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
title: "템플릿 관리",
|
title: "템플릿 관리",
|
||||||
description: "화면 템플릿을 관리하고 재사용하세요",
|
description: "화면 템플릿을 관리하고 재사용하세요",
|
||||||
icon: "📝",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,40 +62,28 @@ export default function ScreenManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 현재 단계가 마지막 단계인지 확인
|
|
||||||
const isLastStep = currentStep === "template";
|
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-white">
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none space-y-6 px-4 py-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">화면 관리</h1>
|
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">화면을 설계하고 템플릿을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 단계별 내용 */}
|
{/* 단계별 내용 */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1">
|
||||||
{/* 화면 목록 단계 */}
|
{/* 화면 목록 단계 */}
|
||||||
{currentStep === "list" && (
|
{currentStep === "list" && (
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
|
||||||
<Button variant="default" className="shadow-sm" onClick={() => goToNextStep("design")}>
|
|
||||||
화면 설계하기 <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ScreenList
|
<ScreenList
|
||||||
onScreenSelect={setSelectedScreen}
|
onScreenSelect={setSelectedScreen}
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
|
|
@ -107,20 +92,26 @@ export default function ScreenManagementPage() {
|
||||||
goToNextStep("design");
|
goToNextStep("design");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 템플릿 관리 단계 */}
|
{/* 템플릿 관리 단계 */}
|
||||||
{currentStep === "template" && (
|
{currentStep === "template" && (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
|
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" className="shadow-sm" onClick={goToPreviousStep}>
|
<Button
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
variant="outline"
|
||||||
|
onClick={goToPreviousStep}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
이전 단계
|
이전 단계
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" className="shadow-sm" onClick={() => goToStep("list")}>
|
<Button
|
||||||
|
onClick={() => goToStep("list")}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
목록으로 돌아가기
|
목록으로 돌아가기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,6 +121,9 @@ export default function ScreenManagementPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react";
|
import { Search, Database, RefreshCw, Settings, Plus, Activity } from "lucide-react";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
|
|
@ -20,7 +18,8 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||||
// 가상화 스크롤링을 위한 간단한 구현
|
import { TableLogViewer } from "@/components/admin/TableLogViewer";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
interface TableInfo {
|
interface TableInfo {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -76,6 +75,10 @@ export default function TableManagementPage() {
|
||||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||||
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 로그 뷰어 상태
|
||||||
|
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||||
|
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
||||||
|
|
||||||
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
||||||
const isSuperAdmin = user?.companyCode === "*";
|
const isSuperAdmin = user?.companyCode === "*";
|
||||||
|
|
||||||
|
|
@ -541,19 +544,24 @@ export default function TableManagementPage() {
|
||||||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
{/* 페이지 제목 */}
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
{getTextFromUI(
|
||||||
|
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
|
||||||
|
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<p className="mt-1 text-sm font-medium text-blue-600">
|
<p className="text-primary mt-1 text-sm font-medium">
|
||||||
🔧 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -564,67 +572,78 @@ export default function TableManagementPage() {
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCreateTableModalOpen(true)}
|
onClick={() => setCreateTableModalOpen(true)}
|
||||||
className="bg-green-600 text-white hover:bg-green-700"
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
size="sm"
|
size="default"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />새 테이블 생성
|
<Plus className="h-4 w-4" />새 테이블 생성
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedTable && (
|
{selectedTable && (
|
||||||
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
|
<Button
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
onClick={() => setAddColumnModalOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
컬럼 추가
|
컬럼 추가
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
|
<Button
|
||||||
<Activity className="mr-2 h-4 w-4" />
|
onClick={() => setDdlLogViewerOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
DDL 로그
|
DDL 로그
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
|
<Button
|
||||||
|
onClick={loadTables}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
<div className="flex h-full gap-6">
|
||||||
{/* 테이블 목록 */}
|
{/* 좌측 사이드바: 테이블 목록 (20%) */}
|
||||||
<Card className="shadow-sm lg:col-span-1">
|
<div className="w-[20%] border-r pr-6">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<div className="space-y-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<Database className="h-5 w-5 text-gray-600" />
|
<Database className="text-muted-foreground h-5 w-5" />
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
|
||||||
</CardTitle>
|
</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="mb-4">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 목록 */}
|
{/* 테이블 목록 */}
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
<div className="space-y-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-sm text-gray-500">
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : tables.length === 0 ? (
|
) : tables.length === 0 ? (
|
||||||
<div className="py-8 text-center text-gray-500">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -637,88 +656,82 @@ export default function TableManagementPage() {
|
||||||
.map((table) => (
|
.map((table) => (
|
||||||
<div
|
<div
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
|
||||||
selectedTable === table.tableName
|
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleTableSelect(table.tableName)}
|
onClick={() => handleTableSelect(table.tableName)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||||
<div>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||||
<Badge variant="secondary">
|
<span className="text-muted-foreground text-xs">컬럼</span>
|
||||||
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{table.columnCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 타입 관리 */}
|
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
||||||
<Card className="shadow-sm lg:col-span-4">
|
<div className="w-[80%] pl-0">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<div className="flex h-full flex-col space-y-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<h2 className="flex items-center gap-2 text-xl font-semibold">
|
||||||
<Settings className="h-5 w-5 text-gray-600" />
|
<Settings className="text-muted-foreground h-5 w-5" />
|
||||||
{selectedTable ? <>테이블 설정 - {selectedTable}</> : "테이블 타입 관리"}
|
{selectedTable ? <>테이블 설정 - {selectedTable}</> : "테이블 타입 관리"}
|
||||||
</CardTitle>
|
</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<div className="flex-1 overflow-hidden">
|
||||||
{!selectedTable ? (
|
{!selectedTable ? (
|
||||||
<div className="py-12 text-center text-gray-500">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 테이블 라벨 설정 */}
|
{/* 테이블 라벨 설정 */}
|
||||||
<div className="mb-6 space-y-4 rounded-lg border border-gray-200 p-4">
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">테이블 정보</h3>
|
<div className="flex-1">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">테이블명 (읽기 전용)</label>
|
|
||||||
<Input value={selectedTable} disabled className="bg-gray-50" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">표시명</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={tableLabel}
|
value={tableLabel}
|
||||||
onChange={(e) => setTableLabel(e.target.value)}
|
onChange={(e) => setTableLabel(e.target.value)}
|
||||||
placeholder="테이블 표시명을 입력하세요"
|
placeholder="테이블 표시명"
|
||||||
|
className="h-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="flex-1">
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">설명</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={tableDescription}
|
value={tableDescription}
|
||||||
onChange={(e) => setTableDescription(e.target.value)}
|
onChange={(e) => setTableDescription(e.target.value)}
|
||||||
placeholder="테이블 설명을 입력하세요"
|
placeholder="테이블 설명"
|
||||||
|
className="h-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{columnsLoading ? (
|
{columnsLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-sm text-gray-500">
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : columns.length === 0 ? (
|
) : columns.length === 0 ? (
|
||||||
<div className="py-8 text-center text-gray-500">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 컬럼 헤더 */}
|
{/* 컬럼 헤더 */}
|
||||||
<div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
|
<div className="text-foreground flex items-center border-b pb-2 text-sm font-semibold">
|
||||||
<div className="w-40 px-4">컬럼명</div>
|
<div className="w-40 px-4">컬럼명</div>
|
||||||
<div className="w-48 px-4">라벨</div>
|
<div className="w-48 px-4">라벨</div>
|
||||||
<div className="w-48 px-4">입력 타입</div>
|
<div className="w-48 px-4">입력 타입</div>
|
||||||
|
|
@ -730,7 +743,7 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
{/* 컬럼 리스트 */}
|
{/* 컬럼 리스트 */}
|
||||||
<div
|
<div
|
||||||
className="max-h-96 overflow-y-auto rounded-lg border border-gray-200"
|
className="max-h-96 overflow-y-auto rounded-lg border"
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||||
|
|
@ -742,17 +755,17 @@ export default function TableManagementPage() {
|
||||||
{columns.map((column, index) => (
|
{columns.map((column, index) => (
|
||||||
<div
|
<div
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className="flex items-center border-b border-gray-200 py-2 hover:bg-gray-50"
|
className="hover:bg-muted/50 flex items-center border-b py-2 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-40 px-4">
|
<div className="w-40 px-4">
|
||||||
<div className="font-mono text-sm text-gray-700">{column.columnName}</div>
|
<div className="font-mono text-sm">{column.columnName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-48 px-4">
|
<div className="w-48 px-4">
|
||||||
<Input
|
<Input
|
||||||
value={column.displayName || ""}
|
value={column.displayName || ""}
|
||||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||||
placeholder={column.columnName}
|
placeholder={column.columnName}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-48 px-4">
|
<div className="w-48 px-4">
|
||||||
|
|
@ -760,7 +773,7 @@ export default function TableManagementPage() {
|
||||||
value={column.inputType || "text"}
|
value={column.inputType || "text"}
|
||||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="입력 타입 선택" />
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -777,9 +790,11 @@ export default function TableManagementPage() {
|
||||||
{column.inputType === "code" && (
|
{column.inputType === "code" && (
|
||||||
<Select
|
<Select
|
||||||
value={column.codeCategory || "none"}
|
value={column.codeCategory || "none"}
|
||||||
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
|
onValueChange={(value) =>
|
||||||
|
handleDetailSettingsChange(column.columnName, "code", value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="공통코드 선택" />
|
<SelectValue placeholder="공통코드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -794,31 +809,38 @@ export default function TableManagementPage() {
|
||||||
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
{column.inputType === "entity" && (
|
{column.inputType === "entity" && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
|
{/* Entity 타입 설정 - 가로 배치 */}
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
|
<div className="border-primary/20 bg-primary/5 rounded-lg border p-2">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-blue-800">Entity 설정</span>
|
<span className="text-primary text-xs font-medium">Entity 설정</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{/* 참조 테이블 */}
|
{/* 참조 테이블 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-gray-600">참조 테이블</label>
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
|
참조 테이블
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceTable || "none"}
|
value={column.referenceTable || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 bg-white text-xs">
|
<SelectTrigger className="bg-background h-8 text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceTableOptions.map((option, index) => (
|
{referenceTableOptions.map((option, index) => (
|
||||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
<SelectItem
|
||||||
|
key={`entity-${option.value}-${index}`}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{option.label}</span>
|
<span className="font-medium">{option.label}</span>
|
||||||
<span className="text-xs text-gray-500">{option.value}</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{option.value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -829,7 +851,9 @@ export default function TableManagementPage() {
|
||||||
{/* 조인 컬럼 */}
|
{/* 조인 컬럼 */}
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-gray-600">조인 컬럼</label>
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
|
조인 컬럼
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceColumn || "none"}
|
value={column.referenceColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -840,7 +864,7 @@ export default function TableManagementPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 bg-white text-xs">
|
<SelectTrigger className="bg-background h-8 text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -857,7 +881,7 @@ export default function TableManagementPage() {
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||||
<SelectItem value="loading" disabled>
|
<SelectItem value="loading" disabled>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border border-blue-500 border-t-transparent"></div>
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
로딩중
|
로딩중
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -875,8 +899,8 @@ export default function TableManagementPage() {
|
||||||
column.referenceColumn !== "none" &&
|
column.referenceColumn !== "none" &&
|
||||||
column.displayColumn &&
|
column.displayColumn &&
|
||||||
column.displayColumn !== "none" && (
|
column.displayColumn !== "none" && (
|
||||||
<div className="mt-1 flex items-center gap-1 rounded bg-green-100 px-2 py-1 text-xs text-green-700">
|
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||||
<span className="text-green-600">✓</span>
|
<span>✓</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -887,7 +911,7 @@ export default function TableManagementPage() {
|
||||||
)}
|
)}
|
||||||
{/* 다른 웹 타입인 경우 빈 공간 */}
|
{/* 다른 웹 타입인 경우 빈 공간 */}
|
||||||
{column.inputType !== "code" && column.inputType !== "entity" && (
|
{column.inputType !== "code" && column.inputType !== "entity" && (
|
||||||
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
|
<div className="text-muted-foreground flex h-8 items-center text-xs">-</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-80 px-4">
|
<div className="w-80 px-4">
|
||||||
|
|
@ -895,7 +919,7 @@ export default function TableManagementPage() {
|
||||||
value={column.description || ""}
|
value={column.description || ""}
|
||||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||||
placeholder="설명"
|
placeholder="설명"
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -906,12 +930,12 @@ export default function TableManagementPage() {
|
||||||
{columnsLoading && (
|
{columnsLoading && (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-sm text-gray-500">더 많은 컬럼 로딩 중...</span>
|
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지 정보 */}
|
{/* 페이지 정보 */}
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -920,7 +944,7 @@ export default function TableManagementPage() {
|
||||||
<Button
|
<Button
|
||||||
onClick={saveAllSettings}
|
onClick={saveAllSettings}
|
||||||
disabled={!selectedTable || columns.length === 0}
|
disabled={!selectedTable || columns.length === 0}
|
||||||
className="flex items-center gap-2"
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
전체 설정 저장
|
전체 설정 저장
|
||||||
|
|
@ -930,8 +954,9 @@ export default function TableManagementPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DDL 모달 컴포넌트들 */}
|
{/* DDL 모달 컴포넌트들 */}
|
||||||
|
|
@ -972,8 +997,15 @@ export default function TableManagementPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||||
|
|
||||||
|
{/* 테이블 로그 뷰어 */}
|
||||||
|
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserManagement } from "@/components/admin/UserManagement";
|
import { UserManagement } from "@/components/admin/UserManagement";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자관리 페이지
|
* 사용자관리 페이지
|
||||||
* URL: /admin/userMng
|
* URL: /admin/userMng
|
||||||
|
*
|
||||||
|
* shadcn/ui 스타일 가이드 적용
|
||||||
*/
|
*/
|
||||||
export default function UserMngPage() {
|
export default function UserMngPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">사용자 관리</h1>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">사용자 관리</h1>
|
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||||
<p className="mt-2 text-gray-600">시스템 사용자 계정 및 권한을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
<UserManagement />
|
<UserManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
*/
|
*/
|
||||||
export default function MainPage() {
|
export default function MainPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pt-10">
|
<div className="space-y-6 px-4 pt-10">
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -18,7 +18,7 @@ export default function MainPage() {
|
||||||
<h3 className="text-lg font-semibold">Vexolor에 오신 것을 환영합니다!</h3>
|
<h3 className="text-lg font-semibold">Vexolor에 오신 것을 환영합니다!</h3>
|
||||||
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="flex justify-center space-x-2">
|
||||||
<Badge variant="secondary">Spring Boot</Badge>
|
<Badge variant="secondary">Node.js</Badge>
|
||||||
<Badge variant="secondary">Next.js</Badge>
|
<Badge variant="secondary">Next.js</Badge>
|
||||||
<Badge variant="secondary">Shadcn/ui</Badge>
|
<Badge variant="secondary">Shadcn/ui</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
export default function MainHomePage() {
|
export default function MainHomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="pt-10 space-y-6">
|
<div className="space-y-6 px-4 pt-10">
|
||||||
{/* 대시보드 컨텐츠 */}
|
{/* 대시보드 컨텐츠 */}
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
||||||
<p className="mb-6 text-gray-600">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
<p className="mb-6 text-gray-600">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-700/10 ring-inset">
|
|
||||||
Spring Boot
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-700/10 ring-inset">
|
<span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-700/10 ring-inset">
|
||||||
Next.js
|
Next.js
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
import { EditModal } from "@/components/screen/EditModal";
|
import { EditModal } from "@/components/screen/EditModal";
|
||||||
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
|
||||||
|
|
||||||
export default function ScreenViewPage() {
|
export default function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -22,12 +21,14 @@ export default function ScreenViewPage() {
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
// 화면 너비에 따라 Y좌표 유지 여부 결정
|
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
||||||
const [preserveYPosition, setPreserveYPosition] = useState(true);
|
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||||
|
|
||||||
const breakpoint = useBreakpoint();
|
// 테이블 새로고침을 위한 키 (값이 변경되면 테이블이 리렌더링됨)
|
||||||
|
const [tableRefreshKey, setTableRefreshKey] = useState(0);
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
|
@ -40,6 +41,10 @@ export default function ScreenViewPage() {
|
||||||
modalDescription?: string;
|
modalDescription?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
// 자동 스케일 조정 (사용자 화면 크기에 맞춤)
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initComponents = async () => {
|
const initComponents = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -124,22 +129,38 @@ export default function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행
|
// 자동 스케일 조정 useEffect (항상 화면에 꽉 차게)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!layout) return;
|
const updateScale = () => {
|
||||||
|
if (containerRef.current && layout) {
|
||||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const availableWidth = containerWidth - 32; // 좌우 패딩 16px * 2
|
||||||
|
|
||||||
const handleResize = () => {
|
// 항상 화면에 맞춰서 스케일 조정 (늘리거나 줄임)
|
||||||
const shouldPreserve = window.innerWidth >= screenWidth - 100;
|
const newScale = availableWidth / screenWidth;
|
||||||
setPreserveYPosition(shouldPreserve);
|
|
||||||
|
console.log("📏 스케일 계산 (화면 꽉 차게):", {
|
||||||
|
screenWidth,
|
||||||
|
containerWidth,
|
||||||
|
availableWidth,
|
||||||
|
scale: newScale,
|
||||||
|
});
|
||||||
|
|
||||||
|
setScale(newScale);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
// 초기 측정 (DOM이 완전히 렌더링된 후)
|
||||||
// 초기 값도 설정
|
const timer = setTimeout(() => {
|
||||||
handleResize();
|
updateScale();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
window.addEventListener("resize", updateScale);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
window.removeEventListener("resize", updateScale);
|
||||||
|
};
|
||||||
}, [layout]);
|
}, [layout]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -172,39 +193,111 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||||
|
const screenHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-white">
|
<div ref={containerRef} className="bg-background flex h-full w-full flex-col overflow-hidden">
|
||||||
<div style={{ padding: "16px 0" }}>
|
{/* 절대 위치 기반 렌더링 */}
|
||||||
{/* 항상 반응형 모드로 렌더링 */}
|
|
||||||
{layout && layout.components.length > 0 ? (
|
{layout && layout.components.length > 0 ? (
|
||||||
<ResponsiveLayoutEngine
|
<div
|
||||||
components={layout?.components || []}
|
className="bg-background relative flex-1"
|
||||||
breakpoint={breakpoint}
|
style={{
|
||||||
containerWidth={window.innerWidth}
|
width: screenWidth,
|
||||||
screenWidth={screenWidth}
|
height: "100%",
|
||||||
preserveYPosition={preserveYPosition}
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 최상위 컴포넌트들 렌더링 */}
|
||||||
|
{layout.components
|
||||||
|
.filter((component) => !component.parentId)
|
||||||
|
.map((component) => (
|
||||||
|
<RealtimePreview
|
||||||
|
key={component.id}
|
||||||
|
component={component}
|
||||||
|
isSelected={false}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
screenId={screenId}
|
||||||
|
tableName={screen?.tableName}
|
||||||
|
selectedRowsData={selectedRowsData}
|
||||||
|
onSelectedRowsChange={(_, selectedData) => {
|
||||||
|
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||||
|
setSelectedRowsData(selectedData);
|
||||||
|
}}
|
||||||
|
refreshKey={tableRefreshKey}
|
||||||
|
onRefresh={() => {
|
||||||
|
console.log("🔄 테이블 새로고침 요청됨");
|
||||||
|
setTableRefreshKey((prev) => prev + 1);
|
||||||
|
setSelectedRowsData([]); // 선택 해제
|
||||||
|
}}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName: string, value: unknown) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log("📝 page.tsx formData 업데이트:", fieldName, value);
|
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 자식 컴포넌트들 */}
|
||||||
|
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||||
|
layout.components
|
||||||
|
.filter((child) => child.parentId === component.id)
|
||||||
|
.map((child) => {
|
||||||
|
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||||
|
const relativeChildComponent = {
|
||||||
|
...child,
|
||||||
|
position: {
|
||||||
|
x: child.position.x - component.position.x,
|
||||||
|
y: child.position.y - component.position.y,
|
||||||
|
z: child.position.z || 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RealtimePreview
|
||||||
|
key={child.id}
|
||||||
|
component={relativeChildComponent}
|
||||||
|
isSelected={false}
|
||||||
|
isDesignMode={false}
|
||||||
|
onClick={() => {}}
|
||||||
|
screenId={screenId}
|
||||||
|
tableName={screen?.tableName}
|
||||||
|
selectedRowsData={selectedRowsData}
|
||||||
|
onSelectedRowsChange={(_, selectedData) => {
|
||||||
|
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||||
|
setSelectedRowsData(selectedData);
|
||||||
|
}}
|
||||||
|
refreshKey={tableRefreshKey}
|
||||||
|
onRefresh={() => {
|
||||||
|
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||||
|
setTableRefreshKey((prev) => prev + 1);
|
||||||
|
setSelectedRowsData([]); // 선택 해제
|
||||||
|
}}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}}
|
}}
|
||||||
screenInfo={{ id: screenId, tableName: screen?.tableName }}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RealtimePreview>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면일 때
|
// 빈 화면일 때
|
||||||
<div className="flex items-center justify-center bg-white" style={{ minHeight: "600px" }}>
|
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
|
||||||
<span className="text-2xl">📄</span>
|
<span className="text-2xl">📄</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
<h2 className="text-foreground mb-2 text-xl font-semibold">화면이 비어있습니다</h2>
|
||||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
<p className="text-muted-foreground">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 편집 모달 */}
|
{/* 편집 모달 */}
|
||||||
<EditModal
|
<EditModal
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue