Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
1760703150
|
|
@ -0,0 +1,604 @@
|
|||
# ERP 시스템 UI/UX 디자인 가이드
|
||||
|
||||
## 📋 문서 목적
|
||||
이 문서는 ERP 시스템의 새로운 페이지나 컴포넌트를 개발할 때 참고할 수 있는 **디자인 시스템 기준안**입니다.
|
||||
일관된 사용자 경험을 위해 모든 개발자는 이 가이드를 따라 개발해주세요.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 시스템 개요
|
||||
|
||||
### 디자인 철학
|
||||
- **일관성**: 모든 페이지에서 동일한 패턴 사용
|
||||
- **명확성**: 직관적이고 이해하기 쉬운 UI
|
||||
- **접근성**: 모든 사용자가 쉽게 사용할 수 있도록
|
||||
- **반응성**: 다양한 화면 크기에 대응
|
||||
|
||||
### 기술 스택
|
||||
- **CSS Framework**: Tailwind CSS
|
||||
- **UI Library**: shadcn/ui
|
||||
- **Icons**: Lucide React
|
||||
|
||||
---
|
||||
|
||||
## 📐 페이지 기본 구조
|
||||
|
||||
### 1. 표준 페이지 레이아웃
|
||||
|
||||
```tsx
|
||||
export default function YourPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">페이지 제목</h1>
|
||||
<p className="mt-2 text-gray-600">페이지 설명</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 구조 설명
|
||||
|
||||
#### 최상위 래퍼
|
||||
```tsx
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
```
|
||||
- `min-h-screen`: 최소 높이를 화면 전체로
|
||||
- `bg-gray-50`: 연한 회색 배경 (전체 페이지 기본 배경)
|
||||
|
||||
#### 컨테이너
|
||||
```tsx
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
```
|
||||
- `w-full max-w-none`: 전체 너비 사용
|
||||
- `px-4`: 좌우 패딩 1rem (16px)
|
||||
- `py-8`: 상하 패딩 2rem (32px)
|
||||
- `space-y-8`: 하위 요소 간 수직 간격 2rem
|
||||
|
||||
#### 헤더 카드
|
||||
```tsx
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제목</h1>
|
||||
<p className="mt-2 text-gray-600">설명</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 컴포넌트 디자인 기준
|
||||
|
||||
### 1. 버튼
|
||||
|
||||
#### 주요 버튼 (Primary)
|
||||
```tsx
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
버튼 텍스트
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 보조 버튼 (Secondary)
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 위험 버튼 (Danger)
|
||||
```tsx
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
삭제
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. 카드 (Card)
|
||||
|
||||
#### 기본 카드
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>카드 제목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
#### 강조 카드
|
||||
```tsx
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Icon className="w-5 h-5 mr-2 text-orange-500" />
|
||||
제목
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700">내용</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 3. 테이블
|
||||
|
||||
#### 기본 테이블 구조
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
컬럼명
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60">
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
데이터
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 4. 폼 (Form)
|
||||
|
||||
#### 입력 필드
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
라벨
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 셀렉트
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">옵션 1</SelectItem>
|
||||
<SelectItem value="2">옵션 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
### 5. 빈 상태 (Empty State)
|
||||
|
||||
```tsx
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Icon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 6. 로딩 상태
|
||||
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 색상 시스템
|
||||
|
||||
### 주 색상 (Primary)
|
||||
```css
|
||||
orange-50 #fff7ed /* 매우 연한 배경 */
|
||||
orange-100 #ffedd5 /* 연한 배경 */
|
||||
orange-500 #f97316 /* 주요 버튼, 강조 */
|
||||
orange-600 #ea580c /* 버튼 호버 */
|
||||
```
|
||||
|
||||
### 회색 (Gray)
|
||||
```css
|
||||
gray-50 #f9fafb /* 페이지 배경 */
|
||||
gray-100 #f3f4f6 /* 카드 내부 구분 */
|
||||
gray-200 #e5e7eb /* 테두리 */
|
||||
gray-300 #d1d5db /* 입력 필드 테두리 */
|
||||
gray-500 #6b7280 /* 보조 텍스트 */
|
||||
gray-600 #4b5563 /* 일반 텍스트 */
|
||||
gray-700 #374151 /* 라벨, 헤더 */
|
||||
gray-800 #1f2937 /* 제목 */
|
||||
gray-900 #111827 /* 주요 제목 */
|
||||
```
|
||||
|
||||
### 상태 색상
|
||||
```css
|
||||
/* 성공 */
|
||||
green-100 #dcfce7
|
||||
green-500 #22c55e
|
||||
green-700 #15803d
|
||||
|
||||
/* 경고 */
|
||||
red-100 #fee2e2
|
||||
red-500 #ef4444
|
||||
red-600 #dc2626
|
||||
|
||||
/* 정보 */
|
||||
blue-50 #eff6ff
|
||||
blue-100 #dbeafe
|
||||
blue-500 #3b82f6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 간격 시스템
|
||||
|
||||
### Spacing Scale
|
||||
```css
|
||||
space-y-2 0.5rem (8px) /* 폼 요소 간 간격 */
|
||||
space-y-4 1rem (16px) /* 섹션 내부 간격 */
|
||||
space-y-6 1.5rem (24px) /* 카드 내부 큰 간격 */
|
||||
space-y-8 2rem (32px) /* 페이지 주요 섹션 간격 */
|
||||
|
||||
gap-2 0.5rem (8px) /* 버튼 그룹 간격 */
|
||||
gap-4 1rem (16px) /* 카드 그리드 간격 */
|
||||
gap-6 1.5rem (24px) /* 큰 카드 그리드 간격 */
|
||||
```
|
||||
|
||||
### Padding
|
||||
```css
|
||||
p-2 0.5rem (8px) /* 작은 요소 */
|
||||
p-4 1rem (16px) /* 일반 요소 */
|
||||
p-6 1.5rem (24px) /* 카드, 헤더 */
|
||||
p-8 2rem (32px) /* 큰 영역 */
|
||||
|
||||
px-3 좌우 0.75rem /* 입력 필드 */
|
||||
px-4 좌우 1rem /* 버튼 */
|
||||
px-6 좌우 1.5rem /* 테이블 셀 */
|
||||
|
||||
py-2 상하 0.5rem /* 버튼 */
|
||||
py-4 상하 1rem /* 입력 필드 */
|
||||
py-8 상하 2rem /* 페이지 컨테이너 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 타이포그래피
|
||||
|
||||
### 제목 (Headings)
|
||||
```css
|
||||
/* 페이지 제목 */
|
||||
text-3xl font-bold text-gray-900
|
||||
/* 예: 30px, Bold, #111827 */
|
||||
|
||||
/* 섹션 제목 */
|
||||
text-2xl font-bold text-gray-900
|
||||
/* 예: 24px, Bold */
|
||||
|
||||
/* 카드 제목 */
|
||||
text-lg font-semibold text-gray-800
|
||||
/* 예: 18px, Semi-bold */
|
||||
|
||||
/* 작은 제목 */
|
||||
text-base font-medium text-gray-700
|
||||
/* 예: 16px, Medium */
|
||||
```
|
||||
|
||||
### 본문 (Body Text)
|
||||
```css
|
||||
/* 일반 텍스트 */
|
||||
text-sm text-gray-600
|
||||
/* 14px, #4b5563 */
|
||||
|
||||
/* 보조 설명 */
|
||||
text-sm text-gray-500
|
||||
/* 14px, #6b7280 */
|
||||
|
||||
/* 라벨 */
|
||||
text-sm font-medium text-gray-700
|
||||
/* 14px, Medium */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎭 인터랙션 패턴
|
||||
|
||||
### 호버 효과
|
||||
```css
|
||||
/* 버튼 호버 */
|
||||
hover:bg-orange-600
|
||||
hover:shadow-md
|
||||
|
||||
/* 카드 호버 */
|
||||
hover:shadow-lg transition-shadow
|
||||
|
||||
/* 테이블 행 호버 */
|
||||
hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60
|
||||
```
|
||||
|
||||
### 포커스 효과
|
||||
```css
|
||||
/* 입력 필드 포커스 */
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-orange-500
|
||||
focus:border-orange-500
|
||||
```
|
||||
|
||||
### 전환 효과
|
||||
```css
|
||||
/* 일반 전환 */
|
||||
transition-all duration-200
|
||||
|
||||
/* 그림자 전환 */
|
||||
transition-shadow
|
||||
|
||||
/* 색상 전환 */
|
||||
transition-colors duration-200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔲 그리드 시스템
|
||||
|
||||
### 반응형 그리드
|
||||
```tsx
|
||||
{/* 1열 → 2열 → 3열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 카드들 */}
|
||||
</div>
|
||||
|
||||
{/* 1열 → 2열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 항목들 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 브레이크포인트
|
||||
```css
|
||||
sm: 640px @media (min-width: 640px)
|
||||
md: 768px @media (min-width: 768px)
|
||||
lg: 1024px @media (min-width: 1024px)
|
||||
xl: 1280px @media (min-width: 1280px)
|
||||
2xl: 1536px @media (min-width: 1536px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 실전 예제
|
||||
|
||||
### 예제 1: 관리 페이지 (데이터 있음)
|
||||
|
||||
```tsx
|
||||
export default function ManagementPage() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadData}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">총 개수</p>
|
||||
<p className="text-2xl font-bold text-gray-900">156</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-3 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 나머지 통계 카드들... */}
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
이름
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
상태
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60"
|
||||
>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
|
||||
활성
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
수정
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-red-500">
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 예제 2: 빈 상태 페이지
|
||||
|
||||
```tsx
|
||||
export default function EmptyStatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Database className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">아직 등록된 데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 데이터 추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Info className="w-5 h-5 mr-2 text-orange-500" />
|
||||
데이터 관리 안내
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 데이터를 추가하여 시스템을 사용해보세요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>기능 설명 1</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>기능 설명 2</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 새 페이지 만들 때
|
||||
- [ ] `min-h-screen bg-gray-50` 래퍼 사용
|
||||
- [ ] 헤더 카드 (`bg-white rounded-lg shadow-sm border p-6`) 포함
|
||||
- [ ] 제목은 `text-3xl font-bold text-gray-900`
|
||||
- [ ] 설명은 `mt-2 text-gray-600`
|
||||
- [ ] 주요 버튼은 `bg-orange-500 hover:bg-orange-600`
|
||||
- [ ] 카드는 `shadow-sm` 클래스 포함
|
||||
- [ ] 간격은 `space-y-8` 사용
|
||||
|
||||
### 새 컴포넌트 만들 때
|
||||
- [ ] 일관된 패딩 사용 (`p-4`, `p-6`)
|
||||
- [ ] 호버 효과 추가
|
||||
- [ ] 전환 애니메이션 적용 (`transition-all duration-200`)
|
||||
- [ ] 적절한 아이콘 사용 (Lucide React)
|
||||
- [ ] 반응형 디자인 고려 (`md:`, `lg:`)
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
### Tailwind CSS 공식 문서
|
||||
- https://tailwindcss.com/docs
|
||||
|
||||
### shadcn/ui 컴포넌트
|
||||
- https://ui.shadcn.com/
|
||||
|
||||
### Lucide 아이콘
|
||||
- https://lucide.dev/icons/
|
||||
|
||||
---
|
||||
|
||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||
|
|
@ -18,13 +18,15 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
|
|
@ -36,13 +38,15 @@
|
|||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
|
|
@ -2312,6 +2316,19 @@
|
|||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||
|
|
@ -3184,6 +3201,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/imap": {
|
||||
"version": "0.8.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
|
|
@ -3250,6 +3277,30 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mailparser": {
|
||||
"version": "3.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
|
||||
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"iconv-lite": "^0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
|
|
@ -3319,9 +3370,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz",
|
||||
"integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==",
|
||||
"version": "6.4.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
||||
"integrity": "sha512-uj83z0GqwqMUE6RI4EKptPlav0FYE6vpIlqJAnxzu+/sSezRdbH69rSBCMsdW6DdsCAzoFQZ52c2UIlhRVQYDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -4868,7 +4919,6 @@
|
|||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -5022,7 +5072,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
|
|
@ -5037,7 +5086,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -5050,7 +5098,6 @@
|
|||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
|
|
@ -5066,7 +5113,6 @@
|
|||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
|
|
@ -5160,11 +5206,19 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
|
|
@ -6198,6 +6252,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
||||
|
|
@ -6214,11 +6277,26 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
|
|
@ -6335,6 +6413,42 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "1.1.x",
|
||||
"utf7": ">=1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imap/node_modules/readable-stream": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.1",
|
||||
"isarray": "0.0.1",
|
||||
"string_decoder": "~0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/string_decoder": {
|
||||
"version": "0.10.31",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -7403,6 +7517,15 @@
|
|||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
|
@ -7427,6 +7550,42 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -7434,6 +7593,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
|
|
@ -7554,6 +7722,56 @@
|
|||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"mailsplit": "5.4.5",
|
||||
"nodemailer": "7.0.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.259.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailsplit": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -8202,6 +8420,19 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
|
@ -8264,6 +8495,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
|
|
@ -8610,6 +8850,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
|
|
@ -8924,6 +9173,18 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
|
|
@ -9525,6 +9786,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
|
|
@ -9771,6 +10041,12 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
|
|
@ -9848,6 +10124,23 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
||||
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
||||
"dependencies": {
|
||||
"semver": "~5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7/node_modules/semver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
||||
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -32,13 +32,15 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
|
|
@ -50,13 +52,15 @@
|
|||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
|||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
|
|
@ -157,6 +161,10 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
|||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailAccountFileService } from '../services/mailAccountFileService';
|
||||
|
||||
export class MailAccountFileController {
|
||||
async getAllAccounts(req: Request, res: Response) {
|
||||
try {
|
||||
const accounts = await mailAccountFileService.getAllAccounts();
|
||||
|
||||
// 비밀번호는 반환하지 않음
|
||||
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccounts,
|
||||
total: safeAccounts.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const account = await mailAccountFileService.getAccountById(id);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호는 마스킹 처리
|
||||
const { smtpPassword, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...safeAccount,
|
||||
smtpPassword: '••••••••', // 마스킹
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit,
|
||||
status,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
|
||||
if (existingAccount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 등록된 이메일입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const account = await mailAccountFileService.createAccount({
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure: smtpSecure || false,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit: dailyLimit || 1000,
|
||||
status: status || 'active',
|
||||
});
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '메일 계정이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const account = await mailAccountFileService.updateAccount(id, updates);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '계정이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailAccountFileService.deleteAccount(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;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const account = await mailAccountFileService.getAccountById(id);
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// mailSendSimpleService의 testConnection 사용
|
||||
const { mailSendSimpleService } = require('../services/mailSendSimpleService');
|
||||
const result = await mailSendSimpleService.testConnection(id);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailAccountFileController = new MailAccountFileController();
|
||||
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* 메일 수신 컨트롤러 (Step 2 - 기본 구현)
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { MailReceiveBasicService } from '../services/mailReceiveBasicService';
|
||||
|
||||
export class MailReceiveBasicController {
|
||||
private mailReceiveService: MailReceiveBasicService;
|
||||
|
||||
constructor() {
|
||||
this.mailReceiveService = new MailReceiveBasicService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId
|
||||
* 메일 목록 조회
|
||||
*/
|
||||
async getMailList(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
const mails = await this.mailReceiveService.fetchMailList(accountId, limit);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: mails,
|
||||
count: mails.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 목록 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 목록 조회 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId/:seqno
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
async getMailDetail(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 mailDetail = await this.mailReceiveService.getMailDetail(accountId, seqnoNumber);
|
||||
|
||||
if (!mailDetail) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '메일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: mailDetail,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('메일 상세 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '메일 상세 조회 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mail/receive/:accountId/:seqno/mark-read
|
||||
* 메일을 읽음으로 표시
|
||||
*/
|
||||
async markAsRead(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.markAsRead(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 : '읽음 표시 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/:accountId/:seqno/attachment/:index
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
async downloadAttachment(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, seqno, index } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
const indexNumber = parseInt(index, 10);
|
||||
|
||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.mailReceiveService.downloadAttachment(
|
||||
accountId,
|
||||
seqnoNumber,
|
||||
indexNumber
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '첨부파일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 다운로드
|
||||
res.download(result.filePath, result.filename, (err) => {
|
||||
if (err) {
|
||||
console.error('파일 다운로드 오류:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '파일 다운로드 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return; // void 반환
|
||||
} catch (error: unknown) {
|
||||
console.error('첨부파일 다운로드 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '첨부파일 다운로드 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mail/receive/:accountId/test-imap
|
||||
* IMAP 연결 테스트
|
||||
*/
|
||||
async testImapConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const result = await this.mailReceiveService.testImapConnection(accountId);
|
||||
|
||||
return res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error: unknown) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'IMAP 연결 테스트 실패',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||
|
||||
export class MailSendSimpleController {
|
||||
/**
|
||||
* 메일 발송 (단건 또는 소규모)
|
||||
*/
|
||||
async sendMail(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
|
||||
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||
console.log('❌ 필수 파라미터 누락');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '메일 제목이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
|
||||
if (!templateId && !customHtml) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 또는 메일 내용이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 메일 발송
|
||||
const result = await mailSendSimpleService.sendMail({
|
||||
accountId,
|
||||
templateId,
|
||||
to,
|
||||
subject,
|
||||
variables,
|
||||
customHtml,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '메일이 발송되었습니다.',
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.error || '메일 발송 실패',
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 발송 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mailSendSimpleService.testConnection(accountId);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleController = new MailSendSimpleController();
|
||||
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailTemplateFileService } from '../services/mailTemplateFileService';
|
||||
|
||||
// 간단한 변수 치환 함수
|
||||
function replaceVariables(text: string, data: Record<string, any>): string {
|
||||
let result = text;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, String(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class MailTemplateFileController {
|
||||
// 모든 템플릿 조회
|
||||
async getAllTemplates(req: Request, res: Response) {
|
||||
try {
|
||||
const { category, search } = req.query;
|
||||
|
||||
let templates;
|
||||
if (search) {
|
||||
templates = await mailTemplateFileService.searchTemplates(search as string);
|
||||
} else if (category) {
|
||||
templates = await mailTemplateFileService.getTemplatesByCategory(category as string);
|
||||
} else {
|
||||
templates = await mailTemplateFileService.getAllTemplates();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
total: templates.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 템플릿 조회
|
||||
async getTemplateById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 생성
|
||||
async createTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, subject, components, queryConfig, recipientConfig, category } = req.body;
|
||||
|
||||
if (!name || !subject || !Array.isArray(components)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 이름, 제목, 컴포넌트가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const template = await mailTemplateFileService.createTemplate({
|
||||
name,
|
||||
subject,
|
||||
components,
|
||||
queryConfig,
|
||||
recipientConfig,
|
||||
category,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 수정
|
||||
async updateTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.updateTemplate(id, updates);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 삭제
|
||||
async deleteTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailTemplateFileService.deleteTemplate(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;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 미리보기 (HTML 렌더링)
|
||||
async previewTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sampleData } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// HTML 렌더링
|
||||
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
|
||||
let subject = template.subject;
|
||||
|
||||
// 샘플 데이터가 있으면 변수 치환
|
||||
if (sampleData) {
|
||||
html = replaceVariables(html, sampleData);
|
||||
subject = replaceVariables(subject, sampleData);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
subject,
|
||||
html,
|
||||
sampleData,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '미리보기 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 + 쿼리 통합 미리보기
|
||||
async previewWithQuery(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { queryId, parameters } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const query = template.queryConfig?.queries.find(q => q.id === queryId);
|
||||
if (!query) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '쿼리를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 쿼리 기능은 구현되지 않음
|
||||
return res.status(501).json({
|
||||
success: false,
|
||||
message: 'SQL 쿼리 연동 기능은 현재 지원하지 않습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 미리보기 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileController = new MailTemplateFileController();
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Router } from 'express';
|
||||
import { mailAccountFileController } from '../controllers/mailAccountFileController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 메일 계정 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get('/', (req, res) => mailAccountFileController.getAllAccounts(req, res));
|
||||
router.get('/:id', (req, res) => mailAccountFileController.getAccountById(req, res));
|
||||
router.post('/', (req, res) => mailAccountFileController.createAccount(req, res));
|
||||
router.put('/:id', (req, res) => mailAccountFileController.updateAccount(req, res));
|
||||
router.delete('/:id', (req, res) => mailAccountFileController.deleteAccount(req, res));
|
||||
router.post('/:id/test-connection', (req, res) => mailAccountFileController.testConnection(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 메일 수신 라우트 (Step 2 - 기본 구현)
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { MailReceiveBasicController } from '../controllers/mailReceiveBasicController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 메일 수신 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
const controller = new MailReceiveBasicController();
|
||||
|
||||
// 메일 목록 조회
|
||||
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||
|
||||
// 메일 상세 조회
|
||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||
|
||||
// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
|
||||
|
||||
// 메일 읽음 표시
|
||||
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||
|
||||
// IMAP 연결 테스트
|
||||
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Router } from 'express';
|
||||
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 메일 발송 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// POST /api/mail/send/simple - 메일 발송
|
||||
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
|
||||
|
||||
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Router } from 'express';
|
||||
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 메일 템플릿 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 템플릿 CRUD
|
||||
router.get('/', (req, res) => mailTemplateFileController.getAllTemplates(req, res));
|
||||
router.get('/:id', (req, res) => mailTemplateFileController.getTemplateById(req, res));
|
||||
router.post('/', (req, res) => mailTemplateFileController.createTemplate(req, res));
|
||||
router.put('/:id', (req, res) => mailTemplateFileController.updateTemplate(req, res));
|
||||
router.delete('/:id', (req, res) => mailTemplateFileController.deleteTemplate(req, res));
|
||||
|
||||
// 미리보기
|
||||
router.post('/:id/preview', (req, res) => mailTemplateFileController.previewTemplate(req, res));
|
||||
router.post('/:id/preview-with-query', (req, res) => mailTemplateFileController.previewWithQuery(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -934,23 +934,14 @@ export class DataflowControlService {
|
|||
}
|
||||
|
||||
/**
|
||||
* DELETE 액션 실행 - 조건 기반으로만 삭제
|
||||
* DELETE 액션 실행 - 보안상 외부 DB 비활성화
|
||||
*/
|
||||
private async executeDeleteAction(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<any> {
|
||||
console.log(`🗑️ DELETE 액션 실행 시작:`, {
|
||||
actionName: action.name,
|
||||
conditions: action.conditions,
|
||||
});
|
||||
|
||||
// DELETE는 조건이 필수
|
||||
if (!action.conditions || action.conditions.length === 0) {
|
||||
throw new Error(
|
||||
"DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다."
|
||||
);
|
||||
}
|
||||
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
|
||||
const results = [];
|
||||
|
||||
|
|
@ -964,7 +955,7 @@ export class DataflowControlService {
|
|||
condition.value !== undefined
|
||||
) {
|
||||
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
|
||||
const parts = condition.field.split(".");
|
||||
const parts = condition.field!.split(".");
|
||||
let tableName: string;
|
||||
let fieldName: string;
|
||||
|
||||
|
|
@ -982,7 +973,7 @@ export class DataflowControlService {
|
|||
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
|
||||
);
|
||||
}
|
||||
fieldName = condition.field;
|
||||
fieldName = condition.field!;
|
||||
}
|
||||
|
||||
if (!tableGroups.has(tableName)) {
|
||||
|
|
@ -1044,14 +1035,14 @@ export class DataflowControlService {
|
|||
targetTable: tableName,
|
||||
whereClause,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error(`❌ DELETE 실패:`, {
|
||||
table: tableName,
|
||||
error: error,
|
||||
});
|
||||
|
||||
const userFriendlyMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
error instanceof Error ? (error as Error).message : String(error);
|
||||
|
||||
results.push({
|
||||
message: `DELETE 실패: ${tableName}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
class EncryptionService {
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor() {
|
||||
const keyString = process.env.ENCRYPTION_KEY;
|
||||
if (!keyString) {
|
||||
throw new Error('ENCRYPTION_KEY environment variable is required');
|
||||
}
|
||||
this.key = crypto.scryptSync(keyString, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipher(this.algorithm, this.key);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
decrypt(encryptedText: string): string {
|
||||
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
||||
|
||||
if (!ivHex || !authTagHex || !encrypted) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipher(this.algorithm, this.key);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// 비밀번호 해싱 (bcrypt 대신 사용)
|
||||
hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||
return salt + ':' + hash;
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hashedPassword: string): boolean {
|
||||
const [salt, hash] = hashedPassword.split(':');
|
||||
const verifyHash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||
return hash === verifyHash;
|
||||
}
|
||||
|
||||
// 랜덤 토큰 생성
|
||||
generateToken(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
// HMAC 서명 생성
|
||||
createHmac(data: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||
}
|
||||
|
||||
// HMAC 검증
|
||||
verifyHmac(data: string, signature: string, secret: string): boolean {
|
||||
const expectedSignature = this.createHmac(data, secret);
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptionService = new EncryptionService();
|
||||
|
|
@ -745,6 +745,30 @@ export class ExternalDbConnectionService {
|
|||
params: any[] = []
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
// 보안 검증: SELECT 쿼리만 허용
|
||||
const trimmedQuery = query.trim().toUpperCase();
|
||||
if (!trimmedQuery.startsWith('SELECT')) {
|
||||
console.log("보안 오류: SELECT가 아닌 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 위험한 키워드 검사
|
||||
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE', 'CALL', 'MERGE'];
|
||||
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
|
||||
trimmedQuery.includes(keyword)
|
||||
);
|
||||
|
||||
if (hasDangerousKeyword) {
|
||||
console.log("보안 오류: 위험한 키워드 포함 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||
return {
|
||||
success: false,
|
||||
message: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 정보 조회
|
||||
console.log("연결 정보 조회 시작:", { id });
|
||||
const connection = await queryOne<any>(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { encryptionService } from './encryptionService';
|
||||
|
||||
export interface MailAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string; // 암호화된 비밀번호
|
||||
dailyLimit: number;
|
||||
status: 'active' | 'inactive' | 'suspended';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class MailAccountFileService {
|
||||
private accountsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.accountsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private getAccountPath(id: string): string {
|
||||
return path.join(this.accountsDir, `${id}.json`);
|
||||
}
|
||||
|
||||
async getAllAccounts(): Promise<MailAccount[]> {
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(this.accountsDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.accountsDir, file),
|
||||
'utf-8'
|
||||
);
|
||||
return JSON.parse(content) as MailAccount;
|
||||
})
|
||||
);
|
||||
|
||||
return accounts.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountById(id: string): Promise<MailAccount | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(
|
||||
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<MailAccount> {
|
||||
const id = `account-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 비밀번호 암호화
|
||||
const encryptedPassword = encryptionService.encrypt(data.smtpPassword);
|
||||
|
||||
const account: MailAccount = {
|
||||
...data,
|
||||
id,
|
||||
smtpPassword: encryptedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(account, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
id: string,
|
||||
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>>
|
||||
): Promise<MailAccount | null> {
|
||||
const existing = await this.getAccountById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 비밀번호가 변경되면 암호화
|
||||
if (data.smtpPassword && data.smtpPassword !== existing.smtpPassword) {
|
||||
data.smtpPassword = encryptionService.encrypt(data.smtpPassword);
|
||||
}
|
||||
|
||||
const updated: MailAccount = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteAccount(id: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.unlink(this.getAccountPath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountByEmail(email: string): Promise<MailAccount | null> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.find(a => a.email === email) || null;
|
||||
}
|
||||
|
||||
async getActiveAccounts(): Promise<MailAccount[]> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.filter(a => a.status === 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 복호화
|
||||
*/
|
||||
decryptPassword(encryptedPassword: string): string {
|
||||
return encryptionService.decrypt(encryptedPassword);
|
||||
}
|
||||
}
|
||||
|
||||
export const mailAccountFileService = new MailAccountFileService();
|
||||
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
/**
|
||||
* 메일 수신 서비스 (Step 2 - 기본 구현)
|
||||
* IMAP 연결 및 메일 목록 조회
|
||||
*/
|
||||
|
||||
import * as Imap from 'imap';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface ReceivedMail {
|
||||
id: string;
|
||||
messageId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: Date;
|
||||
preview: string; // 텍스트 미리보기
|
||||
isRead: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
export interface MailDetail extends ReceivedMail {
|
||||
htmlBody: string; // HTML 본문
|
||||
textBody: string; // 텍스트 본문
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
attachments: Array<{
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ImapConfig {
|
||||
user: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export class MailReceiveBasicService {
|
||||
private attachmentsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.attachmentsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.attachmentsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP 연결 생성
|
||||
*/
|
||||
private createImapConnection(config: ImapConfig): any {
|
||||
return new (Imap as any)({
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: config.tls,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 계정으로 받은 메일 목록 조회
|
||||
*/
|
||||
async fetchMailList(accountId: string, limit: number = 50): Promise<ReceivedMail[]> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword, // 이미 복호화됨
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
const mails: ReceivedMail[] = [];
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const totalMessages = box.messages.total;
|
||||
if (totalMessages === 0) {
|
||||
imap.end();
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
// 최근 메일부터 가져오기
|
||||
const start = Math.max(1, totalMessages - limit + 1);
|
||||
const end = totalMessages;
|
||||
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let attributes: any = null;
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', () => {
|
||||
if (info.which === 'HEADER') {
|
||||
header = buffer;
|
||||
} else {
|
||||
body = buffer;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
msg.once('attributes', (attrs: any) => {
|
||||
attributes = attrs;
|
||||
});
|
||||
|
||||
msg.once('end', async () => {
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
|
||||
mails.push(mail);
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 미리보기 추출 (최대 150자)
|
||||
*/
|
||||
private extractPreview(text: string): string {
|
||||
// HTML 태그 제거
|
||||
const plainText = text.replace(/<[^>]*>/g, '');
|
||||
// 공백 정리
|
||||
const cleaned = plainText.replace(/\s+/g, ' ').trim();
|
||||
// 최대 150자
|
||||
return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
async getMailDetail(accountId: string, seqno: number): Promise<MailDetail | null> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', false, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let mailDetail: MailDetail | null = null;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
|
||||
const bccAddress = Array.isArray(parsed.bcc) ? parsed.bcc[0] : parsed.bcc;
|
||||
|
||||
mailDetail = {
|
||||
id: `${accountId}-${seqnum}`,
|
||||
messageId: parsed.messageId || `${seqnum}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
cc: ccAddress?.text,
|
||||
bcc: bccAddress?.text,
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
htmlBody: parsed.html || '',
|
||||
textBody: parsed.text || '',
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: true, // 조회 시 읽음으로 표시
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
attachments: (parsed.attachments || []).map((att: any) => ({
|
||||
filename: att.filename || 'unnamed',
|
||||
contentType: att.contentType || 'application/octet-stream',
|
||||
size: att.size || 0,
|
||||
})),
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 읽음으로 표시
|
||||
*/
|
||||
async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', false, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => {
|
||||
imap.end();
|
||||
if (flagErr) {
|
||||
reject(flagErr);
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
message: '메일을 읽음으로 표시했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP 연결 테스트
|
||||
*/
|
||||
async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.end();
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'IMAP 연결 성공',
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 타임아웃 설정 (10초)
|
||||
const timeout = setTimeout(() => {
|
||||
imap.end();
|
||||
reject(new Error('연결 시간 초과'));
|
||||
}, 10000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '알 수 없는 오류',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
async downloadAttachment(
|
||||
accountId: string,
|
||||
seqno: number,
|
||||
attachmentIndex: number
|
||||
): Promise<{ filePath: string; filename: string; contentType: string } | null> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
|
||||
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
|
||||
const attachment = parsed.attachments[attachmentIndex];
|
||||
|
||||
// 안전한 파일명 생성
|
||||
const safeFilename = this.sanitizeFilename(
|
||||
attachment.filename || `attachment-${Date.now()}`
|
||||
);
|
||||
const timestamp = Date.now();
|
||||
const filename = `${accountId}-${seqno}-${timestamp}-${safeFilename}`;
|
||||
const filePath = path.join(this.attachmentsDir, filename);
|
||||
|
||||
// 파일 저장
|
||||
await fs.writeFile(filePath, attachment.content);
|
||||
|
||||
attachmentResult = {
|
||||
filePath,
|
||||
filename: attachment.filename || 'unnamed',
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('첨부파일 파싱 오류:', parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 정제 (안전한 파일명 생성)
|
||||
*/
|
||||
private sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.substring(0, 200); // 최대 길이 제한
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* 간단한 메일 발송 서비스 (쿼리 제외)
|
||||
* Nodemailer를 사용한 직접 발송
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
|
||||
export interface SendMailRequest {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
}
|
||||
|
||||
export interface SendMailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class MailSendSimpleService {
|
||||
/**
|
||||
* 단일 메일 발송 또는 소규모 발송
|
||||
*/
|
||||
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
||||
try {
|
||||
// 1. 계정 조회
|
||||
const account = await mailAccountFileService.getAccountById(request.accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 2. 계정 활성화 확인
|
||||
if (account.status !== 'active') {
|
||||
throw new Error('비활성 상태의 계정입니다.');
|
||||
}
|
||||
|
||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||
let htmlContent = request.customHtml || '';
|
||||
|
||||
if (!htmlContent && request.templateId) {
|
||||
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
||||
if (!template) {
|
||||
throw new Error('템플릿을 찾을 수 없습니다.');
|
||||
}
|
||||
htmlContent = this.renderTemplate(template, request.variables);
|
||||
}
|
||||
|
||||
if (!htmlContent) {
|
||||
throw new Error('메일 내용이 없습니다.');
|
||||
}
|
||||
|
||||
// 4. 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
|
||||
// 5. SMTP 연결 생성
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('📧 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure, // SSL/TLS (포트 465는 자동으로 true)
|
||||
auth: {
|
||||
user: account.smtpUsername,
|
||||
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
||||
},
|
||||
// 타임아웃 설정 (30초)
|
||||
connectionTimeout: 30000,
|
||||
greetingTimeout: 30000,
|
||||
});
|
||||
|
||||
console.log('📧 메일 발송 시도 중...');
|
||||
|
||||
// 6. 메일 발송
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${account.name}" <${account.email}>`,
|
||||
to: request.to.join(', '),
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
html: htmlContent,
|
||||
});
|
||||
|
||||
console.log('✅ 메일 발송 성공:', {
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted,
|
||||
rejected: info.rejected,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted as string[],
|
||||
rejected: info.rejected as string[],
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('❌ 메일 발송 실패:', err.message);
|
||||
console.error('❌ 에러 상세:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링 (간단 버전)
|
||||
*/
|
||||
private renderTemplate(
|
||||
template: any,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
template.components.forEach((component: any) => {
|
||||
switch (component.type) {
|
||||
case 'text':
|
||||
let content = component.content || '';
|
||||
if (variables) {
|
||||
content = this.replaceVariables(content, variables);
|
||||
}
|
||||
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
let buttonText = component.text || 'Button';
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
html += `
|
||||
<a href="${component.url || '#'}" style="
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${component.styles?.backgroundColor || '#007bff'};
|
||||
color: ${component.styles?.color || 'white'};
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
${this.styleObjectToString(component.styles)}
|
||||
">${buttonText}</a>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
|
||||
break;
|
||||
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 치환
|
||||
*/
|
||||
private replaceVariables(text: string, variables?: Record<string, string>): string {
|
||||
if (!variables) return text;
|
||||
|
||||
let result = text;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일 객체를 CSS 문자열로 변환
|
||||
*/
|
||||
private styleObjectToString(styles?: Record<string, string>): string {
|
||||
if (!styles) return '';
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
|
||||
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('🔌 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
auth: {
|
||||
user: account.smtpUsername,
|
||||
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
||||
},
|
||||
connectionTimeout: 10000, // 10초 타임아웃
|
||||
greetingTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log('🔌 SMTP 연결 검증 중...');
|
||||
await transporter.verify();
|
||||
console.log('✅ SMTP 연결 검증 성공!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP 연결 성공!',
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('❌ SMTP 연결 실패:', err.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 실패: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
}
|
||||
|
||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||
export interface QueryConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
parameters: any[];
|
||||
}
|
||||
|
||||
export interface MailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
components: MailComponent[];
|
||||
queryConfig?: {
|
||||
queries: QueryConfig[];
|
||||
};
|
||||
recipientConfig?: {
|
||||
type: 'query' | 'manual';
|
||||
emailField?: string;
|
||||
nameField?: string;
|
||||
queryId?: string;
|
||||
manualList?: Array<{ email: string; name?: string }>;
|
||||
};
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class MailTemplateFileService {
|
||||
private templatesDir: string;
|
||||
|
||||
constructor() {
|
||||
// uploads/mail-templates 디렉토리 사용
|
||||
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates');
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리 생성 (없으면)
|
||||
*/
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.templatesDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.templatesDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 파일 경로 생성
|
||||
*/
|
||||
private getTemplatePath(id: string): string {
|
||||
return path.join(this.templatesDir, `${id}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 템플릿 목록 조회
|
||||
*/
|
||||
async getAllTemplates(): Promise<MailTemplate[]> {
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
|
||||
const templates = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.templatesDir, file),
|
||||
'utf-8'
|
||||
);
|
||||
return JSON.parse(content) as MailTemplate;
|
||||
})
|
||||
);
|
||||
|
||||
// 최신순 정렬
|
||||
return templates.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 템플릿 조회
|
||||
*/
|
||||
async getTemplateById(id: string): Promise<MailTemplate | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
*/
|
||||
async createTemplate(
|
||||
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<MailTemplate> {
|
||||
const id = `template-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const template: MailTemplate = {
|
||||
...data,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(template, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 수정
|
||||
*/
|
||||
async updateTemplate(
|
||||
id: string,
|
||||
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
|
||||
): Promise<MailTemplate | null> {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MailTemplate = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
*/
|
||||
async deleteTemplate(id: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.unlink(this.getTemplatePath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿을 HTML로 렌더링
|
||||
*/
|
||||
renderTemplateToHtml(components: MailComponent[]): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
components.forEach(comp => {
|
||||
const styles = Object.entries(comp.styles || {})
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
|
||||
switch (comp.type) {
|
||||
case 'text':
|
||||
html += `<div style="${styles}">${comp.content || ''}</div>`;
|
||||
break;
|
||||
case 'button':
|
||||
html += `<div style="text-align: center; ${styles}">
|
||||
<a href="${comp.url || '#'}"
|
||||
style="display: inline-block; padding: 12px 24px; text-decoration: none;
|
||||
background-color: ${comp.styles?.backgroundColor || '#007bff'};
|
||||
color: ${comp.styles?.color || '#fff'};
|
||||
border-radius: 4px;">
|
||||
${comp.text || 'Button'}
|
||||
</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
html += `<div style="${styles}">
|
||||
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" />
|
||||
</div>`;
|
||||
break;
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 템플릿 조회
|
||||
*/
|
||||
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
|
||||
const allTemplates = await this.getAllTemplates();
|
||||
return allTemplates.filter(t => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 검색
|
||||
*/
|
||||
async searchTemplates(keyword: string): Promise<MailTemplate[]> {
|
||||
const allTemplates = await this.getAllTemplates();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
return allTemplates.filter(t =>
|
||||
t.name.toLowerCase().includes(lowerKeyword) ||
|
||||
t.subject.toLowerCase().includes(lowerKeyword) ||
|
||||
t.category?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileService = new MailTemplateFileService();
|
||||
|
||||
582482
db/ilshin.pgsql
582482
db/ilshin.pgsql
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,43 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Base image (Debian-based for glibc + OpenSSL compatibility)
|
||||
FROM node:20-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
# Install OpenSSL, curl (for healthcheck), and required certs
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dependencies stage (install production dependencies)
|
||||
FROM base AS deps
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
|
||||
|
||||
# Build stage (compile TypeScript)
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
# Runtime image
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy production node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy built files
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Create logs and uploads directories and set permissions (use existing node user with UID 1000)
|
||||
RUN mkdir -p logs uploads && chown -R node:node logs uploads && chmod -R 755 logs uploads
|
||||
|
||||
EXPOSE 3001
|
||||
USER node
|
||||
CMD ["node", "dist/app.js"]
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
# Node.js 백엔드
|
||||
backend:
|
||||
build:
|
||||
context: ../../backend-node
|
||||
dockerfile: ../docker/deploy/backend.Dockerfile
|
||||
container_name: pms-backend-prod
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
HOST: 0.0.0.0
|
||||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com
|
||||
CORS_CREDENTIALS: "true"
|
||||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
volumes:
|
||||
- /home/vexplor/backend_data:/app/uploads
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
|
||||
- traefik.http.routers.backend.entrypoints=websecure,web
|
||||
- traefik.http.routers.backend.tls=true
|
||||
- traefik.http.routers.backend.tls.certresolver=le
|
||||
- traefik.http.services.backend.loadbalancer.server.port=3001
|
||||
|
||||
# Next.js 프론트엔드
|
||||
frontend:
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: ../docker/deploy/frontend.Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
|
||||
container_name: pms-frontend-prod
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_API_URL: https://api.vexplor.com/api
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
volumes:
|
||||
- /home/vexplor/frontend_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
|
||||
- traefik.http.routers.frontend.entrypoints=websecure,web
|
||||
- traefik.http.routers.frontend.tls=true
|
||||
- traefik.http.routers.frontend.tls.certresolver=le
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: toktork_server_default
|
||||
external: true
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Multi-stage build for Next.js
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Disable telemetry during the build
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
|
||||
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Build the application
|
||||
ENV DISABLE_ESLINT_PLUGIN=true
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the Next.js build output
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Production 모드에서는 .next 폴더 전체를 복사
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
|
||||
# node_modules 복사 (production dependencies)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Next.js start 명령어 사용
|
||||
CMD ["npm", "start"]
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ services:
|
|||
- CORS_ORIGIN=http://localhost:9771
|
||||
- CORS_CREDENTIALS=true
|
||||
- LOG_LEVEL=debug
|
||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
volumes:
|
||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||
- /app/node_modules
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,225 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
MailAccount,
|
||||
getMailAccounts,
|
||||
createMailAccount,
|
||||
updateMailAccount,
|
||||
deleteMailAccount,
|
||||
testMailAccountConnection,
|
||||
CreateMailAccountDto,
|
||||
UpdateMailAccountDto,
|
||||
} from "@/lib/api/mail";
|
||||
import MailAccountModal from "@/components/mail/MailAccountModal";
|
||||
import MailAccountTable from "@/components/mail/MailAccountTable";
|
||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
// 배열인지 확인하고 설정
|
||||
if (Array.isArray(data)) {
|
||||
setAccounts(data);
|
||||
} else {
|
||||
console.error('API 응답이 배열이 아닙니다:', data);
|
||||
setAccounts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('계정 로드 실패:', error);
|
||||
setAccounts([]); // 에러 시 빈 배열로 설정
|
||||
// alert('계정 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setModalMode('create');
|
||||
setSelectedAccount(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (account: MailAccount) => {
|
||||
setModalMode('edit');
|
||||
setSelectedAccount(account);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (account: MailAccount) => {
|
||||
setSelectedAccount(account);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createMailAccount(data as CreateMailAccountDto);
|
||||
} else if (modalMode === 'edit' && selectedAccount) {
|
||||
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
|
||||
}
|
||||
await loadAccounts();
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
throw error; // 모달에서 에러 처리
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
await deleteMailAccount(selectedAccount.id);
|
||||
await loadAccounts();
|
||||
alert('계정이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('계정 삭제 실패:', error);
|
||||
alert('계정 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (account: MailAccount) => {
|
||||
try {
|
||||
const newStatus = account.status === 'active' ? 'inactive' : 'active';
|
||||
await updateMailAccount(account.id, { status: newStatus });
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
console.error('상태 변경 실패:', error);
|
||||
alert('상태 변경에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (account: MailAccount) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await testMailAccountConnection(account.id);
|
||||
|
||||
if (result.success) {
|
||||
alert(`✅ SMTP 연결 성공!\n\n${result.message || '정상적으로 연결되었습니다.'}`);
|
||||
} else {
|
||||
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('연결 테스트 실패:', error);
|
||||
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 계정 관리</h1>
|
||||
<p className="mt-2 text-gray-600">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAccounts}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 계정 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<MailAccountTable
|
||||
accounts={accounts}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onTestConnection={handleTestConnection}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Mail className="w-5 h-5 mr-2 text-orange-500" />
|
||||
메일 계정 관리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>Gmail, Naver, 자체 SMTP 서버 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>비밀번호는 암호화되어 안전하게 저장됩니다</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>일일 발송 제한 설정 가능</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<MailAccountModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveAccount}
|
||||
account={selectedAccount}
|
||||
mode={modalMode}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteAccount}
|
||||
title="메일 계정 삭제"
|
||||
message="이 메일 계정을 삭제하시겠습니까?"
|
||||
itemName={selectedAccount?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Inbox,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail";
|
||||
|
||||
interface DashboardStats {
|
||||
totalAccounts: number;
|
||||
totalTemplates: number;
|
||||
sentToday: number;
|
||||
receivedToday: number;
|
||||
sentThisMonth: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function MailDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalAccounts: 0,
|
||||
totalTemplates: 0,
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 계정 수 (apiClient를 통해 토큰 포함)
|
||||
const accounts = await getMailAccounts();
|
||||
|
||||
// 템플릿 수 (apiClient를 통해 토큰 포함)
|
||||
const templates = await getMailTemplates();
|
||||
|
||||
setStats({
|
||||
totalAccounts: accounts.length,
|
||||
totalTemplates: templates.length,
|
||||
sentToday: 0, // TODO: 실제 발송 통계 API 연동
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('통계 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "등록된 계정",
|
||||
value: stats.totalAccounts,
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "템플릿 수",
|
||||
value: stats.totalTemplates,
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-500",
|
||||
},
|
||||
{
|
||||
title: "오늘 발송",
|
||||
value: stats.sentToday,
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-500",
|
||||
},
|
||||
{
|
||||
title: "오늘 수신",
|
||||
value: stats.receivedToday,
|
||||
icon: Inbox,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 관리 대시보드</h1>
|
||||
<p className="mt-2 text-gray-600">메일 시스템의 전체 현황을 확인합니다</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{stat.title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`${stat.bgColor} p-3 rounded-lg`}>
|
||||
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번 달 통계 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2 text-orange-500" />
|
||||
이번 달 발송 통계
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">총 발송 건수</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{stats.sentThisMonth}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">성공률</span>
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{stats.successRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<TrendingUp className="w-4 h-4 mr-2 text-green-500" />
|
||||
전월 대비 12% 증가
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2 text-blue-500" />
|
||||
최근 활동
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">최근 활동이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle>빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a
|
||||
href="/admin/mail/accounts"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Users className="w-8 h-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">계정 관리</p>
|
||||
<p className="text-sm text-gray-500">메일 계정 설정</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/templates"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<FileText className="w-8 h-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">템플릿 관리</p>
|
||||
<p className="text-sm text-gray-500">템플릿 편집</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/send"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Send className="w-8 h-8 text-orange-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일 발송</p>
|
||||
<p className="text-sm text-gray-500">메일 보내기</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/receive"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Inbox className="w-8 h-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">수신함</p>
|
||||
<p className="text-sm text-gray-500">받은 메일 확인</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Mail className="w-5 h-5 mr-2 text-orange-500" />
|
||||
메일 관리 시스템 안내
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 메일 관리 시스템의 주요 기능을 확인하세요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>SMTP 계정을 등록하여 메일 발송</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>드래그 앤 드롭으로 템플릿 디자인</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>동적 변수와 SQL 쿼리 연동</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>발송 통계 및 이력 관리</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Inbox,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Paperclip,
|
||||
AlertCircle,
|
||||
Search,
|
||||
Filter,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
MailAccount,
|
||||
ReceivedMail,
|
||||
getMailAccounts,
|
||||
getReceivedMails,
|
||||
testImapConnection,
|
||||
} from "@/lib/api/mail";
|
||||
import MailDetailModal from "@/components/mail/MailDetailModal";
|
||||
|
||||
export default function MailReceivePage() {
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// 메일 상세 모달 상태
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
||||
|
||||
// 검색 및 필터 상태
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
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
|
||||
|
||||
// 계정 목록 로드
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
// 계정 선택 시 메일 로드
|
||||
useEffect(() => {
|
||||
if (selectedAccountId) {
|
||||
loadMails();
|
||||
}
|
||||
}, [selectedAccountId]);
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
useEffect(() => {
|
||||
if (!selectedAccountId) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadMails();
|
||||
}, 30000); // 30초
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedAccountId]);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
if (Array.isArray(data)) {
|
||||
const activeAccounts = data.filter((acc) => acc.status === "active");
|
||||
setAccounts(activeAccounts);
|
||||
if (activeAccounts.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(activeAccounts[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("계정 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMails = async () => {
|
||||
if (!selectedAccountId) return;
|
||||
|
||||
setLoading(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const data = await getReceivedMails(selectedAccountId, 50);
|
||||
setMails(data);
|
||||
} catch (error) {
|
||||
console.error("메일 로드 실패:", error);
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "메일을 불러오는데 실패했습니다."
|
||||
);
|
||||
setMails([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!selectedAccountId) return;
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testImapConnection(selectedAccountId);
|
||||
setTestResult(result);
|
||||
if (result.success) {
|
||||
// 연결 성공 후 자동으로 메일 로드
|
||||
setTimeout(() => loadMails(), 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "IMAP 연결 테스트 실패",
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}분 전`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}시간 전`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}일 전`;
|
||||
} else {
|
||||
return date.toLocaleDateString("ko-KR");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailClick = (mail: ReceivedMail) => {
|
||||
setSelectedMailId(mail.id);
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleMailRead = () => {
|
||||
// 메일을 읽었으므로 목록 새로고침
|
||||
loadMails();
|
||||
};
|
||||
|
||||
// 필터링 및 정렬된 메일 목록
|
||||
const filteredAndSortedMails = React.useMemo(() => {
|
||||
let result = [...mails];
|
||||
|
||||
// 검색
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(mail) =>
|
||||
mail.subject.toLowerCase().includes(searchLower) ||
|
||||
mail.from.toLowerCase().includes(searchLower) ||
|
||||
mail.preview.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// 필터
|
||||
if (filterStatus === "unread") {
|
||||
result = result.filter((mail) => !mail.isRead);
|
||||
} else if (filterStatus === "read") {
|
||||
result = result.filter((mail) => mail.isRead);
|
||||
} else if (filterStatus === "attachment") {
|
||||
result = result.filter((mail) => mail.hasAttachments);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortBy === "date-desc") {
|
||||
result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
} else if (sortBy === "date-asc") {
|
||||
result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
} else if (sortBy === "from-asc") {
|
||||
result.sort((a, b) => a.from.localeCompare(b.from));
|
||||
} else if (sortBy === "from-desc") {
|
||||
result.sort((a, b) => b.from.localeCompare(a.from));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [mails, searchTerm, filterStatus, sortBy]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 수신함</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
IMAP으로 받은 메일을 확인합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMails}
|
||||
disabled={loading || !selectedAccountId}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !selectedAccountId}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
연결 테스트
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계정 선택 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||
메일 계정:
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">계정 선택</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name} ({account.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 연결 테스트 결과 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-800 border border-green-200"
|
||||
: "bg-red-50 text-red-800 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
)}
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
{selectedAccountId && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
{/* 검색 */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="제목, 발신자, 내용으로 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="unread">읽지 않음</option>
|
||||
<option value="read">읽음</option>
|
||||
<option value="attachment">첨부파일 있음</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{sortBy.includes("desc") ? (
|
||||
<SortDesc className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<SortAsc className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="date-desc">날짜 ↓ (최신순)</option>
|
||||
<option value="date-asc">날짜 ↑ (오래된순)</option>
|
||||
<option value="from-asc">발신자 ↑ (A-Z)</option>
|
||||
<option value="from-desc">발신자 ↓ (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 카운트 */}
|
||||
{(searchTerm || filterStatus !== "all") && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
||||
{searchTerm && (
|
||||
<span className="ml-2">
|
||||
(검색어: <span className="font-medium text-orange-600">{searchTerm}</span>)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 메일 목록 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<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-gray-600">메일을 불러오는 중...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredAndSortedMails.length === 0 ? (
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">
|
||||
{!selectedAccountId
|
||||
? "메일 계정을 선택하세요"
|
||||
: searchTerm || filterStatus !== "all"
|
||||
? "검색 결과가 없습니다"
|
||||
: "받은 메일이 없습니다"}
|
||||
</p>
|
||||
{selectedAccountId && (
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
variant="outline"
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
IMAP 연결 테스트
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-orange-500" />
|
||||
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{filteredAndSortedMails.map((mail) => (
|
||||
<div
|
||||
key={mail.id}
|
||||
onClick={() => handleMailClick(mail)}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
||||
!mail.isRead ? "bg-blue-50/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 읽음 표시 */}
|
||||
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
||||
{!mail.isRead && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메일 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
mail.isRead
|
||||
? "text-gray-600"
|
||||
: "text-gray-900 font-semibold"
|
||||
}`}
|
||||
>
|
||||
{mail.from}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{mail.hasAttachments && (
|
||||
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(mail.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-sm mb-1 truncate ${
|
||||
mail.isRead ? "text-gray-700" : "text-gray-900 font-medium"
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">
|
||||
{mail.preview}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
||||
메일 수신 기능 완성! 🎉
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
✅ 구현 완료된 모든 기능:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📬 기본 기능</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>IMAP 프로토콜 메일 수신</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>메일 목록 표시</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>읽음/안읽음 상태</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>첨부파일 유무 표시</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📄 상세보기</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>HTML 본문 렌더링</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>텍스트 본문 보기</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>자동 읽음 처리</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>첨부파일 다운로드</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔍 고급 기능</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>통합 검색 (제목/발신자/내용)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>필터링 (읽음/첨부파일)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>정렬 (날짜/발신자)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>자동 새로고침 (30초)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔒 보안</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>XSS 방지 (DOMPurify)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>비밀번호 암호화</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>안전한 파일명 생성</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 메일 상세 모달 */}
|
||||
<MailDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
accountId={selectedAccountId}
|
||||
mailId={selectedMailId}
|
||||
onMailRead={handleMailRead}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Send, Mail, Eye, Plus, X, Loader2, CheckCircle } from "lucide-react";
|
||||
import {
|
||||
MailAccount,
|
||||
MailTemplate,
|
||||
getMailAccounts,
|
||||
getMailTemplates,
|
||||
sendMail,
|
||||
extractTemplateVariables,
|
||||
renderTemplateToHtml,
|
||||
} from "@/lib/api/mail";
|
||||
|
||||
export default function MailSendPage() {
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [subject, setSubject] = useState<string>("");
|
||||
const [recipients, setRecipients] = useState<string[]>([""]);
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
|
||||
// UI 상태
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountsData, templatesData] = await Promise.all([
|
||||
getMailAccounts(),
|
||||
getMailTemplates(),
|
||||
]);
|
||||
setAccounts(Array.isArray(accountsData) ? accountsData : []);
|
||||
setTemplates(Array.isArray(templatesData) ? templatesData : []);
|
||||
|
||||
// 기본값 설정
|
||||
if (accountsData.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(accountsData[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
const templateVariables = selectedTemplate
|
||||
? extractTemplateVariables(selectedTemplate)
|
||||
: [];
|
||||
|
||||
// 템플릿 선택 시 제목 자동 입력 및 변수 초기화
|
||||
useEffect(() => {
|
||||
if (selectedTemplate) {
|
||||
setSubject(selectedTemplate.subject);
|
||||
const initialVars: Record<string, string> = {};
|
||||
templateVariables.forEach((varName) => {
|
||||
initialVars[varName] = "";
|
||||
});
|
||||
setVariables(initialVars);
|
||||
}
|
||||
}, [selectedTemplateId]);
|
||||
|
||||
const addRecipient = () => {
|
||||
setRecipients([...recipients, ""]);
|
||||
};
|
||||
|
||||
const removeRecipient = (index: number) => {
|
||||
setRecipients(recipients.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateRecipient = (index: number, value: string) => {
|
||||
const newRecipients = [...recipients];
|
||||
newRecipients[index] = value;
|
||||
setRecipients(newRecipients);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
// 유효성 검증
|
||||
const validRecipients = recipients.filter((email) => email.trim() !== "");
|
||||
if (validRecipients.length === 0) {
|
||||
alert("수신자 이메일을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccountId) {
|
||||
alert("발송 계정을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
alert("메일 제목을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTemplateId) {
|
||||
alert("템플릿을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
setSendResult(null);
|
||||
|
||||
try {
|
||||
const result = await sendMail({
|
||||
accountId: selectedAccountId,
|
||||
templateId: selectedTemplateId,
|
||||
to: validRecipients,
|
||||
subject,
|
||||
variables,
|
||||
});
|
||||
|
||||
setSendResult({
|
||||
success: true,
|
||||
message: `${result.accepted?.length || 0}개 발송 성공`,
|
||||
});
|
||||
|
||||
// 성공 후 초기화
|
||||
setRecipients([""]);
|
||||
setVariables({});
|
||||
} catch (error) {
|
||||
setSendResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "발송 실패",
|
||||
});
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewHtml = selectedTemplate
|
||||
? renderTemplateToHtml(selectedTemplate, variables)
|
||||
: "";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-6xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 발송</h1>
|
||||
<p className="mt-2 text-gray-600">템플릿을 선택하여 메일을 발송합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 폼 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 왼쪽: 발송 설정 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
발송 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 발송 계정 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
발송 계정 *
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">계정 선택</option>
|
||||
{accounts
|
||||
.filter((acc) => acc.status === "active")
|
||||
.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name} ({account.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
템플릿 *
|
||||
</label>
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">템플릿 선택</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 메일 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
메일 제목 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="예: 환영합니다!"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신자 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
수신자 이메일 *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{recipients.map((email, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => updateRecipient(index, e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
{recipients.length > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeRecipient(index)}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRecipient}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
수신자 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 변수 */}
|
||||
{templateVariables.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
템플릿 변수
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{templateVariables.map((varName) => (
|
||||
<div key={varName}>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{varName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={variables[varName] || ""}
|
||||
onChange={(e) =>
|
||||
setVariables({ ...variables, [varName]: e.target.value })
|
||||
}
|
||||
placeholder={`{${varName}}`}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발송 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!selectedTemplateId}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isSending || !selectedAccountId || !selectedTemplateId}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
발송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
발송
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 발송 결과 */}
|
||||
{sendResult && (
|
||||
<Card
|
||||
className={
|
||||
sendResult.success
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-red-200 bg-red-50"
|
||||
}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{sendResult.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
<p
|
||||
className={
|
||||
sendResult.success ? "text-green-800" : "text-red-800"
|
||||
}
|
||||
>
|
||||
{sendResult.message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 미리보기 */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Eye className="w-4 h-4 text-orange-500" />
|
||||
미리보기
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showPreview && previewHtml ? (
|
||||
<div className="border rounded-lg p-4 bg-white max-h-[600px] overflow-y-auto">
|
||||
<div className="text-xs text-gray-500 mb-2">제목: {subject}</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<Mail className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">
|
||||
템플릿을 선택하고
|
||||
<br />
|
||||
미리보기 버튼을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileText, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import {
|
||||
MailTemplate,
|
||||
getMailTemplates,
|
||||
createMailTemplate,
|
||||
updateMailTemplate,
|
||||
deleteMailTemplate,
|
||||
CreateMailTemplateDto,
|
||||
UpdateMailTemplateDto,
|
||||
} from "@/lib/api/mail";
|
||||
import MailTemplateCard from "@/components/mail/MailTemplateCard";
|
||||
import MailTemplatePreviewModal from "@/components/mail/MailTemplatePreviewModal";
|
||||
import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
|
||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
|
||||
// 모달 상태
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
// 템플릿 목록 불러오기
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMailTemplates();
|
||||
setTemplates(data);
|
||||
} catch (error) {
|
||||
console.error('템플릿 로드 실패:', error);
|
||||
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
// 필터링된 템플릿
|
||||
const filteredTemplates = templates.filter((template) => {
|
||||
const matchesSearch =
|
||||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
template.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory =
|
||||
categoryFilter === 'all' || template.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// 카테고리 목록 추출
|
||||
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditorMode('create');
|
||||
setSelectedTemplate(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (template: MailTemplate) => {
|
||||
setEditorMode('edit');
|
||||
setSelectedTemplate(template);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenPreviewModal = (template: MailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (template: MailTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
|
||||
try {
|
||||
if (editorMode === 'create') {
|
||||
await createMailTemplate(data as CreateMailTemplateDto);
|
||||
} else if (editorMode === 'edit' && selectedTemplate) {
|
||||
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
|
||||
}
|
||||
await loadTemplates();
|
||||
setIsEditorOpen(false);
|
||||
} catch (error) {
|
||||
throw error; // 모달에서 에러 처리
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
try {
|
||||
await deleteMailTemplate(selectedTemplate.id);
|
||||
await loadTemplates();
|
||||
alert('템플릿이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('템플릿 삭제 실패:', error);
|
||||
alert('템플릿 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateTemplate = async (template: MailTemplate) => {
|
||||
try {
|
||||
await createMailTemplate({
|
||||
name: `${template.name} (복사본)`,
|
||||
subject: template.subject,
|
||||
components: template.components,
|
||||
category: template.category,
|
||||
});
|
||||
await loadTemplates();
|
||||
alert('템플릿이 복사되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('템플릿 복사 실패:', error);
|
||||
alert('템플릿 복사에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 템플릿 관리</h1>
|
||||
<p className="mt-2 text-gray-600">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemplates}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 템플릿 만들기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="템플릿 이름, 제목으로 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="all">전체 카테고리</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">
|
||||
{templates.length === 0
|
||||
? '아직 생성된 템플릿이 없습니다'
|
||||
: '검색 결과가 없습니다'}
|
||||
</p>
|
||||
{templates.length === 0 && (
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 템플릿 만들기
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template) => (
|
||||
<MailTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onPreview={handleOpenPreviewModal}
|
||||
onDuplicate={handleDuplicateTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2 text-orange-500" />
|
||||
템플릿 디자이너
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>실시간 미리보기로 즉시 확인 가능</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>동적 변수 지원 (예: {"{customer_name}"})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<MailTemplateEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSave={handleSaveTemplate}
|
||||
template={selectedTemplate}
|
||||
mode={editorMode}
|
||||
/>
|
||||
|
||||
<MailTemplatePreviewModal
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
template={selectedTemplate}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteTemplate}
|
||||
title="템플릿 삭제"
|
||||
message="이 템플릿을 삭제하시겠습니까?"
|
||||
itemName={selectedTemplate?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ export default function ScreenManagementPage() {
|
|||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToNextStep("design")}>
|
||||
<Button variant="default" className="shadow-sm" onClick={() => goToNextStep("design")}>
|
||||
화면 설계하기 <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -121,7 +121,7 @@ export default function ScreenManagementPage() {
|
|||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
이전 단계
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToStep("list")}>
|
||||
<Button variant="default" className="shadow-sm" onClick={() => goToStep("list")}>
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default function TableManagementPage() {
|
|||
setUiTexts(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("다국어 텍스트 로드 실패:", error);
|
||||
// console.error("다국어 텍스트 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -125,20 +125,20 @@ export default function TableManagementPage() {
|
|||
// 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵
|
||||
const existingColumns = referenceTableColumns[tableName];
|
||||
if (existingColumns && existingColumns.length > 0) {
|
||||
console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
|
||||
// console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
|
||||
// console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
|
||||
try {
|
||||
const result = await entityJoinApi.getReferenceTableColumns(tableName);
|
||||
console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
|
||||
// console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
|
||||
setReferenceTableColumns((prev) => ({
|
||||
...prev,
|
||||
[tableName]: result.columns,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
|
||||
// console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
|
||||
setReferenceTableColumns((prev) => ({
|
||||
...prev,
|
||||
[tableName]: [],
|
||||
|
|
@ -177,24 +177,24 @@ export default function TableManagementPage() {
|
|||
const loadCommonCodeCategories = async () => {
|
||||
try {
|
||||
const response = await commonCodeApi.categories.getList({ isActive: true });
|
||||
console.log("🔍 공통코드 카테고리 API 응답:", response);
|
||||
// console.log("🔍 공통코드 카테고리 API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("📋 공통코드 카테고리 데이터:", response.data);
|
||||
// console.log("📋 공통코드 카테고리 데이터:", response.data);
|
||||
|
||||
const categories = response.data.map((category) => {
|
||||
console.log("🏷️ 카테고리 항목:", category);
|
||||
// console.log("🏷️ 카테고리 항목:", category);
|
||||
return {
|
||||
value: category.category_code,
|
||||
label: category.category_name || category.category_code,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("✅ 매핑된 카테고리 옵션:", categories);
|
||||
// console.log("✅ 매핑된 카테고리 옵션:", categories);
|
||||
setCommonCodeCategories(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("공통코드 카테고리 로드 실패:", error);
|
||||
// console.error("공통코드 카테고리 로드 실패:", error);
|
||||
// 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능)
|
||||
}
|
||||
};
|
||||
|
|
@ -213,7 +213,7 @@ export default function TableManagementPage() {
|
|||
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
// console.error("테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -251,7 +251,7 @@ export default function TableManagementPage() {
|
|||
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 타입 정보 로드 실패:", error);
|
||||
// console.error("컬럼 타입 정보 로드 실패:", error);
|
||||
toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
|
|
@ -411,7 +411,7 @@ export default function TableManagementPage() {
|
|||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
};
|
||||
|
||||
console.log("저장할 컬럼 설정:", columnSetting);
|
||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
||||
columnSetting,
|
||||
|
|
@ -430,7 +430,7 @@ export default function TableManagementPage() {
|
|||
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 설정 저장 실패:", error);
|
||||
// console.error("컬럼 설정 저장 실패:", error);
|
||||
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -448,7 +448,7 @@ export default function TableManagementPage() {
|
|||
description: tableDescription,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
||||
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +467,7 @@ export default function TableManagementPage() {
|
|||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
}));
|
||||
|
||||
console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
||||
// console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
||||
|
||||
// 전체 테이블 설정을 한 번에 저장
|
||||
const response = await apiClient.post(
|
||||
|
|
@ -492,7 +492,7 @@ export default function TableManagementPage() {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("설정 저장 실패:", error);
|
||||
// console.error("설정 저장 실패:", error);
|
||||
toast.error("설정 저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -525,7 +525,7 @@ export default function TableManagementPage() {
|
|||
|
||||
entityColumns.forEach((col) => {
|
||||
if (col.referenceTable) {
|
||||
console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
|
||||
// console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
|
||||
loadReferenceTableColumns(col.referenceTable);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
required: true,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
@ -72,7 +72,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
required: true,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
@ -94,7 +94,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
},
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
@ -112,7 +112,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
required: false,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
@ -130,7 +130,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
required: false,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
@ -152,7 +152,7 @@ const TEST_COMPONENTS: ComponentData[] = [
|
|||
},
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#3b83f6",
|
||||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import { Badge } from "@/components/ui/badge";
|
|||
*/
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<div className="pt-10 space-y-6">
|
||||
<div className="space-y-6 pt-10">
|
||||
{/* 메인 컨텐츠 */}
|
||||
{/* Welcome Message */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6 text-center">
|
||||
<h3 className="text-lg font-semibold">PLM 솔루션에 오신 것을 환영합니다!</h3>
|
||||
<h3 className="text-lg font-semibold">Vexolor에 오신 것을 환영합니다!</h3>
|
||||
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Badge variant="secondary">Spring Boot</Badge>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export default function ScreenViewPage() {
|
|||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 pt-10">
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 p-10">
|
||||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
|
|
@ -162,7 +162,6 @@ export default function ScreenViewPage() {
|
|||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
margin: "0 auto 40px auto", // 하단 여백 추가
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
|
|
@ -243,7 +242,7 @@ export default function ScreenViewPage() {
|
|||
const labelText = component.style?.labelText || component.label || "";
|
||||
const labelStyle = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#3b83f6",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
padding: component.style?.labelPadding || "0",
|
||||
|
|
@ -383,7 +382,7 @@ export default function ScreenViewPage() {
|
|||
) : (
|
||||
// 빈 화면일 때도 깔끔하게 표시
|
||||
<div
|
||||
className="mx-auto flex items-center justify-center bg-white"
|
||||
className="mx-auto flex items-center justify-center bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* Z-Index 계층 구조 */
|
||||
--z-background: 1;
|
||||
--z-layout: 10;
|
||||
--z-content: 50;
|
||||
--z-floating: 100;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 2000;
|
||||
--z-critical: 3000;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export default function RootLayout({
|
|||
<Toaster position="top-right" richColors />
|
||||
<ScreenModal />
|
||||
</QueryProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
<div id="portal-root" data-radix-portal="true" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
|||
// 파일 아이콘 가져오기
|
||||
const getFileIcon = (fileName: string, size: number = 16) => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconProps = { size, className: "text-gray-600" };
|
||||
const iconProps = { size, className: "text-muted-foreground" };
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
|
||||
return <Image {...iconProps} className="text-blue-600" />;
|
||||
return <Image {...iconProps} className="text-primary" />;
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
|
||||
return <Video {...iconProps} className="text-purple-600" />;
|
||||
|
|
@ -71,7 +71,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
|||
return <Archive {...iconProps} className="text-yellow-600" />;
|
||||
}
|
||||
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
||||
return <FileText {...iconProps} className="text-red-600" />;
|
||||
return <FileText {...iconProps} className="text-destructive" />;
|
||||
}
|
||||
return <File {...iconProps} />;
|
||||
};
|
||||
|
|
@ -272,7 +272,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(file)}
|
||||
className="flex items-center gap-1 text-red-600 hover:text-red-700"
|
||||
className="flex items-center gap-1 text-destructive hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
|||
toast.error(result.error?.details || result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("컬럼 추가 실패:", error);
|
||||
// console.error("컬럼 추가 실패:", error);
|
||||
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export default function BatchJobModal({
|
|||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Y': return 'bg-green-100 text-green-800';
|
||||
case 'N': return 'bg-red-100 text-red-800';
|
||||
case 'N': return 'bg-destructive/20 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
|
@ -314,29 +314,29 @@ export default function BatchJobModal({
|
|||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{formData.execution_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">총 실행 횟수</div>
|
||||
<div className="text-sm text-muted-foreground">총 실행 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formData.success_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">성공 횟수</div>
|
||||
<div className="text-sm text-muted-foreground">성공 횟수</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
<div className="text-2xl font-bold text-destructive">
|
||||
{formData.failure_count || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">실패 횟수</div>
|
||||
<div className="text-sm text-muted-foreground">실패 횟수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.last_executed_at && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
마지막 실행: {new Date(formData.last_executed_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
|||
"cursor-pointer transition-colors",
|
||||
category.is_active === "Y"
|
||||
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
|
||||
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
|
||||
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
|
@ -71,7 +71,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
|||
{category.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{category.category_code}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{category.category_code}</p>
|
||||
{category.description && <p className="mt-1 text-sm text-gray-500">{category.description}</p>}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -180,11 +180,11 @@ export function CodeCategoryFormModal({
|
|||
{...createForm.register("categoryCode")}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리 코드를 입력하세요"
|
||||
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
|
||||
className={createForm.formState.errors.categoryCode ? "border-destructive" : ""}
|
||||
onBlur={() => handleFieldBlur("categoryCode")}
|
||||
/>
|
||||
{createForm.formState.errors.categoryCode && (
|
||||
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryCode.message}</p>
|
||||
)}
|
||||
{!createForm.formState.errors.categoryCode && (
|
||||
<ValidationMessage
|
||||
|
|
@ -200,7 +200,7 @@ export function CodeCategoryFormModal({
|
|||
{isEditing && editingCategory && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryCodeDisplay">카테고리 코드</Label>
|
||||
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-gray-50" />
|
||||
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-muted" />
|
||||
<p className="text-sm text-gray-500">카테고리 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -216,20 +216,20 @@ export function CodeCategoryFormModal({
|
|||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.categoryName
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.categoryName
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("categoryName")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.categoryName && (
|
||||
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryName.message}</p>
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.categoryName && (
|
||||
<p className="text-sm text-red-600">{createForm.formState.errors.categoryName.message}</p>
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p>
|
||||
)}
|
||||
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
|
||||
<ValidationMessage
|
||||
|
|
@ -251,20 +251,20 @@ export function CodeCategoryFormModal({
|
|||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.categoryNameEng
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("categoryNameEng")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryNameEng.message}</p>
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-red-600">{createForm.formState.errors.categoryNameEng.message}</p>
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
|
||||
)}
|
||||
{!(isEditing
|
||||
? updateForm.formState.errors.categoryNameEng
|
||||
|
|
@ -289,20 +289,20 @@ export function CodeCategoryFormModal({
|
|||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.description
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.description
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
onBlur={() => handleFieldBlur("description")}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">{updateForm.formState.errors.description.message}</p>
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">{createForm.formState.errors.description.message}</p>
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -320,19 +320,19 @@ export function CodeCategoryFormModal({
|
|||
className={
|
||||
isEditing
|
||||
? updateForm.formState.errors.sortOrder
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
: createForm.formState.errors.sortOrder
|
||||
? "border-red-500"
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{isEditing
|
||||
? updateForm.formState.errors.sortOrder && (
|
||||
<p className="text-sm text-red-600">{updateForm.formState.errors.sortOrder.message}</p>
|
||||
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p>
|
||||
)
|
||||
: createForm.formState.errors.sortOrder && (
|
||||
<p className="text-sm text-red-600">{createForm.formState.errors.sortOrder.message}</p>
|
||||
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600">카테고리를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="text-destructive">카테고리를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
|
|
@ -116,7 +116,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
|||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-sm text-gray-600">
|
||||
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
|
||||
활성 카테고리만 표시
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="text-destructive">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
|
||||
다시 시도
|
||||
</Button>
|
||||
|
|
@ -155,7 +155,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="activeOnlyCodes" className="text-sm text-gray-600">
|
||||
<label htmlFor="activeOnlyCodes" className="text-sm text-muted-foreground">
|
||||
활성 코드만 표시
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -221,13 +221,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
|||
"transition-colors",
|
||||
activeCode.isActive === "Y" || activeCode.is_active === "Y"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-600",
|
||||
: "bg-gray-100 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{activeCode.codeValue || activeCode.code_value}
|
||||
</p>
|
||||
{activeCode.description && (
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
{...form.register("codeValue")}
|
||||
disabled={isLoading || isEditing} // 수정 시에는 비활성화
|
||||
placeholder="코드값을 입력하세요"
|
||||
className={(form.formState.errors as any)?.codeValue ? "border-red-500" : ""}
|
||||
className={(form.formState.errors as any)?.codeValue ? "border-destructive" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value && !isEditing) {
|
||||
|
|
@ -180,7 +180,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
}}
|
||||
/>
|
||||
{(form.formState.errors as any)?.codeValue && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
|
||||
<p className="text-sm text-destructive">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
|
||||
)}
|
||||
{!isEditing && !(form.formState.errors as any)?.codeValue && (
|
||||
<ValidationMessage
|
||||
|
|
@ -199,7 +199,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
{...form.register("codeName")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드명을 입력하세요"
|
||||
className={form.formState.errors.codeName ? "border-red-500" : ""}
|
||||
className={form.formState.errors.codeName ? "border-destructive" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
|
|
@ -211,7 +211,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
}}
|
||||
/>
|
||||
{form.formState.errors.codeName && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeName)}</p>
|
||||
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeName)}</p>
|
||||
)}
|
||||
{!form.formState.errors.codeName && (
|
||||
<ValidationMessage
|
||||
|
|
@ -230,7 +230,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
{...form.register("codeNameEng")}
|
||||
disabled={isLoading}
|
||||
placeholder="코드 영문명을 입력하세요"
|
||||
className={form.formState.errors.codeNameEng ? "border-red-500" : ""}
|
||||
className={form.formState.errors.codeNameEng ? "border-destructive" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
|
|
@ -242,7 +242,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
}}
|
||||
/>
|
||||
{form.formState.errors.codeNameEng && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
|
||||
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
|
||||
)}
|
||||
{!form.formState.errors.codeNameEng && (
|
||||
<ValidationMessage
|
||||
|
|
@ -262,10 +262,10 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
disabled={isLoading}
|
||||
placeholder="설명을 입력하세요"
|
||||
rows={3}
|
||||
className={form.formState.errors.description ? "border-red-500" : ""}
|
||||
className={form.formState.errors.description ? "border-destructive" : ""}
|
||||
/>
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.description)}</p>
|
||||
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.description)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -278,10 +278,10 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
|||
{...form.register("sortOrder", { valueAsNumber: true })}
|
||||
disabled={isLoading}
|
||||
min={1}
|
||||
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
|
||||
className={form.formState.errors.sortOrder ? "border-destructive" : ""}
|
||||
/>
|
||||
{form.formState.errors.sortOrder && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.sortOrder)}</p>
|
||||
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.sortOrder)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
|||
const hasRowError = rowErrors.length > 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className={hasRowError ? "bg-red-50" : ""}>
|
||||
<TableRow key={index} className={hasRowError ? "bg-destructive/10" : ""}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
|
|
@ -199,7 +199,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
|
|||
className={hasRowError ? "border-red-300" : ""}
|
||||
/>
|
||||
{rowErrors.length > 0 && (
|
||||
<div className="space-y-1 text-xs text-red-600">
|
||||
<div className="space-y-1 text-xs text-destructive">
|
||||
{rowErrors.map((error, i) => (
|
||||
<div key={i}>{error}</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
);
|
||||
};
|
||||
// 상태에 따른 Badge 색상 결정
|
||||
console.log(companies);
|
||||
// console.log(companies);
|
||||
// 로딩 상태 렌더링
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
|||
toast.error("검증 실패. 오류를 확인해주세요.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("테이블 검증 실패:", error);
|
||||
// console.error("테이블 검증 실패:", error);
|
||||
toast.error("검증 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
|
|
@ -210,7 +210,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
|||
toast.error(result.error?.details || result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("테이블 생성 실패:", error);
|
||||
// console.error("테이블 생성 실패:", error);
|
||||
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -248,7 +248,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
|||
placeholder="예: customer_info"
|
||||
className={tableNameError ? "border-red-300" : ""}
|
||||
/>
|
||||
{tableNameError && <p className="text-sm text-red-600">{tableNameError}</p>}
|
||||
{tableNameError && <p className="text-sm text-destructive">{tableNameError}</p>}
|
||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
setLogs(logsResult.logs);
|
||||
setStatistics(statsResult);
|
||||
} catch (error) {
|
||||
console.error("DDL 로그 로드 실패:", error);
|
||||
// console.error("DDL 로그 로드 실패:", error);
|
||||
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
|
|
@ -101,7 +101,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
||||
loadData(false);
|
||||
} catch (error) {
|
||||
console.error("로그 정리 실패:", error);
|
||||
// console.error("로그 정리 실패:", error);
|
||||
toast.error("로그 정리에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -271,14 +271,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
{log.success ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span className={log.success ? "text-green-600" : "text-red-600"}>
|
||||
<span className={log.success ? "text-green-600" : "text-destructive"}>
|
||||
{log.success ? "성공" : "실패"}
|
||||
</span>
|
||||
</div>
|
||||
{log.error_message && (
|
||||
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{log.error_message}</div>
|
||||
<div className="mt-1 max-w-xs truncate text-xs text-destructive">{log.error_message}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -325,7 +325,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{statistics.failedExecutions}</div>
|
||||
<div className="text-2xl font-bold text-destructive">{statistics.failedExecutions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -374,13 +374,13 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
{statistics.recentFailures.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-red-600">최근 실패 로그</CardTitle>
|
||||
<CardTitle className="text-base text-destructive">최근 실패 로그</CardTitle>
|
||||
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{statistics.recentFailures.map((failure, index) => (
|
||||
<div key={index} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div key={index} className="rounded-lg border border-destructive/20 bg-destructive/10 p-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
||||
|
|
@ -390,7 +390,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
|||
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-red-600">{failure.error_message}</div>
|
||||
<div className="text-sm text-destructive">{failure.error_message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
|
|||
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
summary.totalSizeMB > 1000 ? "bg-red-500" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
|
||||
summary.totalSizeMB > 1000 ? "bg-destructive/100" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
className={`rounded-md border p-3 text-sm ${
|
||||
testResult.success
|
||||
? "border-green-200 bg-green-50 text-green-800"
|
||||
: "border-red-200 bg-red-50 text-red-800"
|
||||
: "border-destructive/20 bg-destructive/10 text-red-800"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{testResult.success ? "✅ 연결 성공" : "❌ 연결 실패"}</div>
|
||||
|
|
@ -469,7 +469,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
{!testResult.success && testResult.error && (
|
||||
<div className="mt-2 text-xs">
|
||||
<div>오류 코드: {testResult.error.code}</div>
|
||||
{testResult.error.details && <div className="mt-1 text-red-600">{testResult.error.details}</div>}
|
||||
{testResult.error.details && <div className="mt-1 text-destructive">{testResult.error.details}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -238,10 +238,10 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<div className="mb-6 flex items-center justify-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${step === "basic" ? "text-blue-600" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
className={`flex items-center gap-2 ${step === "basic" ? "text-primary" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-blue-100 text-blue-600" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-primary/20 text-primary" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
|
|
@ -249,19 +249,19 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
</div>
|
||||
<div className="h-px w-8 bg-gray-300" />
|
||||
<div
|
||||
className={`flex items-center gap-2 ${step === "template" ? "text-blue-600" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
className={`flex items-center gap-2 ${step === "template" ? "text-primary" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-blue-100 text-blue-600" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-primary/20 text-primary" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">템플릿 선택</span>
|
||||
</div>
|
||||
<div className="h-px w-8 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-primary" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-blue-100 text-blue-600" : "bg-gray-100"}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-primary/20 text-primary" : "bg-gray-100"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
|
|
@ -304,13 +304,13 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<Card
|
||||
key={category.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
formData.category === category.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
formData.category === category.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconComponent className="h-5 w-5 text-gray-600" />
|
||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
<div className="text-xs text-gray-500">{category.description}</div>
|
||||
|
|
@ -346,7 +346,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<Card
|
||||
key={template.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
formData.template === template.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
formData.template === template.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
|
|
@ -362,7 +362,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<div className="font-medium">{template.name}</div>
|
||||
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{template.description}</div>
|
||||
<div className="text-sm text-muted-foreground">{template.description}</div>
|
||||
<div className="text-xs text-gray-500">예: {template.example}</div>
|
||||
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
||||
</div>
|
||||
|
|
@ -427,7 +427,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<div className="space-y-4">
|
||||
{generationResult ? (
|
||||
<Alert
|
||||
className={generationResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}
|
||||
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
||||
|
|
@ -479,7 +479,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
|||
<div>
|
||||
<strong>생성될 파일:</strong>
|
||||
</div>
|
||||
<ul className="ml-4 space-y-1 text-xs text-gray-600">
|
||||
<ul className="ml-4 space-y-1 text-xs text-muted-foreground">
|
||||
<li>• {formData.name.toLowerCase()}/index.ts</li>
|
||||
<li>
|
||||
• {formData.name.toLowerCase()}/{formData.name}Layout.tsx
|
||||
|
|
|
|||
|
|
@ -46,21 +46,21 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
parentCompanyCode,
|
||||
uiTexts,
|
||||
}) => {
|
||||
console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
||||
isOpen,
|
||||
menuId,
|
||||
parentId,
|
||||
menuType,
|
||||
level,
|
||||
parentCompanyCode,
|
||||
});
|
||||
// console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
||||
// isOpen,
|
||||
// menuId,
|
||||
// parentId,
|
||||
// menuType,
|
||||
// level,
|
||||
// parentCompanyCode,
|
||||
// });
|
||||
|
||||
// 다국어 텍스트 가져오기 함수
|
||||
const getText = (key: string, fallback?: string): string => {
|
||||
return uiTexts[key] || fallback || key;
|
||||
};
|
||||
|
||||
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
||||
// console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
||||
|
||||
const [formData, setFormData] = useState<MenuFormData>({
|
||||
parentObjId: parentId || "0",
|
||||
|
|
@ -93,20 +93,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
try {
|
||||
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||
|
||||
console.log("🔍 화면 목록 로드 디버깅:", {
|
||||
totalScreens: response.data.length,
|
||||
firstScreen: response.data[0],
|
||||
firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||
firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
||||
allScreenIds: response.data
|
||||
.map((s) => ({
|
||||
screenId: s.screenId,
|
||||
legacyId: s.id,
|
||||
name: s.screenName,
|
||||
code: s.screenCode,
|
||||
}))
|
||||
.slice(0, 5), // 처음 5개만 출력
|
||||
});
|
||||
// console.log("🔍 화면 목록 로드 디버깅:", {
|
||||
// totalScreens: response.data.length,
|
||||
// firstScreen: response.data[0],
|
||||
// firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||
// firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
||||
// allScreenIds: response.data
|
||||
// .map((s) => ({
|
||||
// screenId: s.screenId,
|
||||
// legacyId: s.id,
|
||||
// name: s.screenName,
|
||||
// code: s.screenCode,
|
||||
// }))
|
||||
// .slice(0, 5), // 처음 5개만 출력
|
||||
// });
|
||||
|
||||
setScreens(response.data);
|
||||
console.log("✅ 화면 목록 로드 완료:", response.data.length);
|
||||
|
|
@ -118,14 +118,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
|
||||
// 화면 선택 시 URL 자동 설정
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
console.log("🖥️ 화면 선택 디버깅:", {
|
||||
screen,
|
||||
screenId: screen.screenId,
|
||||
screenIdType: typeof screen.screenId,
|
||||
legacyId: screen.id,
|
||||
allFields: Object.keys(screen),
|
||||
screenValues: Object.values(screen),
|
||||
});
|
||||
// console.log("🖥️ 화면 선택 디버깅:", {
|
||||
// screen,
|
||||
// screenId: screen.screenId,
|
||||
// screenIdType: typeof screen.screenId,
|
||||
// legacyId: screen.id,
|
||||
// allFields: Object.keys(screen),
|
||||
// screenValues: Object.values(screen),
|
||||
// });
|
||||
|
||||
// ScreenDefinition에서는 screenId 필드를 사용
|
||||
const actualScreenId = screen.screenId || screen.id;
|
||||
|
|
@ -154,26 +154,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
menuUrl: screenUrl,
|
||||
}));
|
||||
|
||||
console.log("🖥️ 화면 선택 완료:", {
|
||||
screenId: screen.screenId,
|
||||
legacyId: screen.id,
|
||||
actualScreenId,
|
||||
screenName: screen.screenName,
|
||||
menuType: menuType,
|
||||
formDataMenuType: formData.menuType,
|
||||
isAdminMenu,
|
||||
generatedUrl: screenUrl,
|
||||
});
|
||||
// console.log("🖥️ 화면 선택 완료:", {
|
||||
// screenId: screen.screenId,
|
||||
// legacyId: screen.id,
|
||||
// actualScreenId,
|
||||
// screenName: screen.screenName,
|
||||
// menuType: menuType,
|
||||
// formDataMenuType: formData.menuType,
|
||||
// isAdminMenu,
|
||||
// generatedUrl: screenUrl,
|
||||
// });
|
||||
};
|
||||
|
||||
// URL 타입 변경 시 처리
|
||||
const handleUrlTypeChange = (type: "direct" | "screen") => {
|
||||
console.log("🔄 URL 타입 변경:", {
|
||||
from: urlType,
|
||||
to: type,
|
||||
currentSelectedScreen: selectedScreen?.screenName,
|
||||
currentUrl: formData.menuUrl,
|
||||
});
|
||||
// console.log("🔄 URL 타입 변경:", {
|
||||
// from: urlType,
|
||||
// to: type,
|
||||
// currentSelectedScreen: selectedScreen?.screenName,
|
||||
// currentUrl: formData.menuUrl,
|
||||
// });
|
||||
|
||||
setUrlType(type);
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
try {
|
||||
setLoading(true);
|
||||
console.log("API 호출 시작 - menuId:", menuId);
|
||||
console.log("API URL:", `/admin/menus/${menuId}`);
|
||||
// console.log("API URL:", `/admin/menus/${menuId}`);
|
||||
|
||||
const response = await menuApi.getMenuInfo(menuId);
|
||||
console.log("메뉴 정보 조회 응답:", response);
|
||||
|
|
@ -285,29 +285,29 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
|
||||
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||
if (screenId) {
|
||||
console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
||||
menuUrl,
|
||||
screenId,
|
||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
currentScreensCount: screens.length,
|
||||
});
|
||||
// console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
||||
// menuUrl,
|
||||
// screenId,
|
||||
// hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
// currentScreensCount: screens.length,
|
||||
// });
|
||||
|
||||
// 화면 설정 함수
|
||||
const setScreenFromId = () => {
|
||||
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||
if (screen) {
|
||||
setSelectedScreen(screen);
|
||||
console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
||||
screen,
|
||||
originalUrl: menuUrl,
|
||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
});
|
||||
// console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
||||
// screen,
|
||||
// originalUrl: menuUrl,
|
||||
// hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
// });
|
||||
return true;
|
||||
} else {
|
||||
console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
||||
screenId,
|
||||
availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
||||
});
|
||||
// console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
||||
// screenId,
|
||||
// availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
||||
// });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -330,26 +330,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setSelectedScreen(null);
|
||||
}
|
||||
|
||||
console.log("설정된 폼 데이터:", {
|
||||
objid: menu.objid || menu.OBJID,
|
||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
||||
menuUrl: menu.menu_url || menu.MENU_URL || "",
|
||||
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
||||
seq: menu.seq || menu.SEQ || 1,
|
||||
menuType: convertedMenuType,
|
||||
status: convertedStatus,
|
||||
companyCode: companyCode,
|
||||
langKey: langKey,
|
||||
});
|
||||
// console.log("설정된 폼 데이터:", {
|
||||
// objid: menu.objid || menu.OBJID,
|
||||
// parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||
// menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
||||
// menuUrl: menu.menu_url || menu.MENU_URL || "",
|
||||
// menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
||||
// seq: menu.seq || menu.SEQ || 1,
|
||||
// menuType: convertedMenuType,
|
||||
// status: convertedStatus,
|
||||
// companyCode: companyCode,
|
||||
// langKey: langKey,
|
||||
// });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("메뉴 정보 로딩 오류:", error);
|
||||
console.error("오류 상세 정보:", {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
response: error?.response,
|
||||
});
|
||||
// console.error("오류 상세 정보:", {
|
||||
// message: error?.message,
|
||||
// stack: error?.stack,
|
||||
// response: error?.response,
|
||||
// });
|
||||
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -390,13 +390,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
langKey: "", // 다국어 키 초기화
|
||||
});
|
||||
|
||||
console.log("메뉴 등록 기본값 설정:", {
|
||||
parentObjId: parentId || "0",
|
||||
menuType: defaultMenuType,
|
||||
status: "ACTIVE",
|
||||
companyCode: "",
|
||||
langKey: "",
|
||||
});
|
||||
// console.log("메뉴 등록 기본값 설정:", {
|
||||
// parentObjId: parentId || "0",
|
||||
// menuType: defaultMenuType,
|
||||
// status: "ACTIVE",
|
||||
// companyCode: "",
|
||||
// langKey: "",
|
||||
// });
|
||||
}
|
||||
}, [menuId, parentId, menuType]);
|
||||
|
||||
|
|
@ -448,11 +448,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||
if (screen) {
|
||||
setSelectedScreen(screen);
|
||||
console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
||||
screenId,
|
||||
screenName: screen.screenName,
|
||||
menuUrl,
|
||||
});
|
||||
// console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
||||
// screenId,
|
||||
// screenName: screen.screenName,
|
||||
// menuUrl,
|
||||
// });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -826,10 +826,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
|
||||
{/* 선택된 화면 정보 표시 */}
|
||||
{selectedScreen && (
|
||||
<div className="rounded-md border bg-blue-50 p-3">
|
||||
<div className="rounded-md border bg-accent p-3">
|
||||
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
|
||||
<div className="text-xs text-blue-600">코드: {selectedScreen.screenCode}</div>
|
||||
<div className="text-xs text-blue-600">생성된 URL: {formData.menuUrl}</div>
|
||||
<div className="text-xs text-primary">코드: {selectedScreen.screenCode}</div>
|
||||
<div className="text-xs text-primary">생성된 URL: {formData.menuUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export const MenuManagement: React.FC = () => {
|
|||
defaultTexts[key] = defaultText;
|
||||
});
|
||||
setUiTexts(defaultTexts);
|
||||
console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
|
||||
// console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
|
||||
};
|
||||
|
||||
// 기본 텍스트 반환 함수
|
||||
|
|
@ -303,20 +303,20 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
// uiTexts 상태 변경 감지
|
||||
useEffect(() => {
|
||||
console.log("🔄 uiTexts 상태 변경됨:", {
|
||||
count: Object.keys(uiTexts).length,
|
||||
sampleKeys: Object.keys(uiTexts).slice(0, 5),
|
||||
sampleValues: Object.entries(uiTexts)
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => `${k}: ${v}`),
|
||||
});
|
||||
// console.log("🔄 uiTexts 상태 변경됨:", {
|
||||
// count: Object.keys(uiTexts).length,
|
||||
// sampleKeys: Object.keys(uiTexts).slice(0, 5),
|
||||
// sampleValues: Object.entries(uiTexts)
|
||||
// .slice(0, 3)
|
||||
// .map(([k, v]) => `${k}: ${v}`),
|
||||
// });
|
||||
}, [uiTexts]);
|
||||
|
||||
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (userLang && !uiTextsLoading) {
|
||||
console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
|
||||
// console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
|
||||
loadUITexts();
|
||||
}
|
||||
}, 300); // 300ms 후 실행
|
||||
|
|
@ -328,7 +328,7 @@ export const MenuManagement: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
|
||||
console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
|
||||
// console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
|
||||
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
|
||||
if (!userLang) {
|
||||
initializeDefaultTexts();
|
||||
|
|
@ -378,15 +378,15 @@ export const MenuManagement: React.FC = () => {
|
|||
}, [isCompanyDropdownOpen]);
|
||||
|
||||
const loadMenus = async (showLoading = true) => {
|
||||
console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
|
||||
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
|
||||
try {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
await refreshMenus();
|
||||
console.log("📋 메뉴 목록 조회 성공");
|
||||
// console.log("📋 메뉴 목록 조회 성공");
|
||||
} catch (error) {
|
||||
console.error("❌ 메뉴 목록 조회 실패:", error);
|
||||
// console.error("❌ 메뉴 목록 조회 실패:", error);
|
||||
toast.error(getUITextSync("message.error.load.menu.list"));
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
|
|
@ -397,21 +397,21 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
// 회사 목록 조회
|
||||
const loadCompanies = async () => {
|
||||
console.log("🏢 회사 목록 조회 시작");
|
||||
// console.log("🏢 회사 목록 조회 시작");
|
||||
try {
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
|
||||
if (response.data.success) {
|
||||
console.log("🏢 회사 목록 응답:", response.data);
|
||||
// console.log("🏢 회사 목록 응답:", response.data);
|
||||
const companyList = response.data.data.map((company: any) => ({
|
||||
code: company.company_code || company.companyCode,
|
||||
name: company.company_name || company.companyName,
|
||||
}));
|
||||
console.log("🏢 변환된 회사 목록:", companyList);
|
||||
// console.log("🏢 변환된 회사 목록:", companyList);
|
||||
setCompanies(companyList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 회사 목록 조회 실패:", error);
|
||||
// console.error("❌ 회사 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -421,7 +421,7 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
// userLang이 설정되지 않았으면 기본값 설정
|
||||
if (!userLang) {
|
||||
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
|
||||
// console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
|
||||
const defaultTexts: Record<string, string> = {};
|
||||
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
|
||||
|
|
@ -432,7 +432,7 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
|
||||
if (Object.keys(uiTexts).length === 0) {
|
||||
console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
|
||||
// console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
|
||||
const defaultTexts: Record<string, string> = {};
|
||||
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||
defaultTexts[key] = getDefaultText(key);
|
||||
|
|
@ -440,14 +440,14 @@ export const MenuManagement: React.FC = () => {
|
|||
setUiTexts(defaultTexts);
|
||||
}
|
||||
|
||||
console.log("🌐 UI 다국어 텍스트 로드 시작", {
|
||||
userLang,
|
||||
apiParams: {
|
||||
companyCode: "*",
|
||||
menuCode: "menu.management",
|
||||
userLang: userLang,
|
||||
},
|
||||
});
|
||||
// console.log("🌐 UI 다국어 텍스트 로드 시작", {
|
||||
// userLang,
|
||||
// apiParams: {
|
||||
// companyCode: "*",
|
||||
// menuCode: "menu.management",
|
||||
// userLang: userLang,
|
||||
// },
|
||||
// });
|
||||
setUiTextsLoading(true);
|
||||
|
||||
try {
|
||||
|
|
@ -467,28 +467,28 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
if (response.data.success) {
|
||||
const translations = response.data.data;
|
||||
console.log("🌐 배치 다국어 텍스트 응답:", translations);
|
||||
// console.log("🌐 배치 다국어 텍스트 응답:", translations);
|
||||
|
||||
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
|
||||
const mergedTranslations = { ...uiTexts, ...translations };
|
||||
console.log("🔧 setUiTexts 호출 전:", {
|
||||
translationsCount: Object.keys(translations).length,
|
||||
mergedCount: Object.keys(mergedTranslations).length,
|
||||
});
|
||||
// console.log("🔧 setUiTexts 호출 전:", {
|
||||
// translationsCount: Object.keys(translations).length,
|
||||
// mergedCount: Object.keys(mergedTranslations).length,
|
||||
// });
|
||||
setUiTexts(mergedTranslations);
|
||||
console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
|
||||
// console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
|
||||
|
||||
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
|
||||
setTranslationCache(userLang, mergedTranslations);
|
||||
} else {
|
||||
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
|
||||
// console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
|
||||
// API 실패 시에도 기존 uiTexts는 유지
|
||||
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
||||
// console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
||||
// API 실패 시에도 기존 uiTexts는 유지
|
||||
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||
} finally {
|
||||
setUiTextsLoading(false);
|
||||
}
|
||||
|
|
@ -519,12 +519,12 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
// 다국어 API 테스트 함수 (getUITextSync 사용)
|
||||
const testMultiLangAPI = async () => {
|
||||
console.log("🧪 다국어 API 테스트 시작");
|
||||
// console.log("🧪 다국어 API 테스트 시작");
|
||||
try {
|
||||
const text = getUITextSync("menu.management.admin");
|
||||
console.log("🧪 다국어 API 테스트 결과:", text);
|
||||
// console.log("🧪 다국어 API 테스트 결과:", text);
|
||||
} catch (error) {
|
||||
console.error("❌ 다국어 API 테스트 실패:", error);
|
||||
// console.error("❌ 다국어 API 테스트 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -576,14 +576,14 @@ export const MenuManagement: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleEditMenu = (menuId: string) => {
|
||||
console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
|
||||
// console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
|
||||
|
||||
// 현재 메뉴 정보 찾기
|
||||
const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus;
|
||||
const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
|
||||
|
||||
if (menuToEdit) {
|
||||
console.log("수정할 메뉴 정보:", menuToEdit);
|
||||
// console.log("수정할 메뉴 정보:", menuToEdit);
|
||||
|
||||
setFormData({
|
||||
menuId: menuId,
|
||||
|
|
@ -593,15 +593,15 @@ export const MenuManagement: React.FC = () => {
|
|||
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
||||
});
|
||||
|
||||
console.log("설정된 formData:", {
|
||||
menuId: menuId,
|
||||
parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
|
||||
menuType: selectedMenuType,
|
||||
level: 0,
|
||||
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
||||
});
|
||||
// console.log("설정된 formData:", {
|
||||
// menuId: menuId,
|
||||
// parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
|
||||
// menuType: selectedMenuType,
|
||||
// level: 0,
|
||||
// parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
||||
// });
|
||||
} else {
|
||||
console.error("수정할 메뉴를 찾을 수 없음:", menuId);
|
||||
// console.error("수정할 메뉴를 찾을 수 없음:", menuId);
|
||||
}
|
||||
|
||||
setFormModalOpen(true);
|
||||
|
|
@ -640,31 +640,31 @@ export const MenuManagement: React.FC = () => {
|
|||
setDeleting(true);
|
||||
try {
|
||||
const menuIds = Array.from(selectedMenus);
|
||||
console.log("삭제할 메뉴 IDs:", menuIds);
|
||||
// console.log("삭제할 메뉴 IDs:", menuIds);
|
||||
|
||||
toast.info(getUITextSync("message.menu.delete.processing"));
|
||||
|
||||
const response = await menuApi.deleteMenusBatch(menuIds);
|
||||
console.log("삭제 API 응답:", response);
|
||||
console.log("응답 구조:", {
|
||||
success: response.success,
|
||||
data: response.data,
|
||||
message: response.message,
|
||||
});
|
||||
// console.log("삭제 API 응답:", response);
|
||||
// console.log("응답 구조:", {
|
||||
// success: response.success,
|
||||
// data: response.data,
|
||||
// message: response.message,
|
||||
// });
|
||||
|
||||
if (response.success && response.data) {
|
||||
const { deletedCount, failedCount } = response.data;
|
||||
console.log("삭제 결과:", { deletedCount, failedCount });
|
||||
// console.log("삭제 결과:", { deletedCount, failedCount });
|
||||
|
||||
// 선택된 메뉴 초기화
|
||||
setSelectedMenus(new Set());
|
||||
|
||||
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
|
||||
console.log("메뉴 목록 새로고침 시작");
|
||||
// console.log("메뉴 목록 새로고침 시작");
|
||||
await loadMenus(false);
|
||||
// 전역 메뉴 상태도 업데이트
|
||||
await refreshMenus();
|
||||
console.log("메뉴 목록 새로고침 완료");
|
||||
// console.log("메뉴 목록 새로고침 완료");
|
||||
|
||||
// 삭제 결과 메시지
|
||||
if (failedCount === 0) {
|
||||
|
|
@ -678,11 +678,11 @@ export const MenuManagement: React.FC = () => {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
console.error("삭제 실패:", response);
|
||||
// console.error("삭제 실패:", response);
|
||||
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 삭제 중 오류:", error);
|
||||
// console.error("메뉴 삭제 중 오류:", error);
|
||||
toast.error(getUITextSync("message.menu.delete.failed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
|
|
@ -718,7 +718,7 @@ export const MenuManagement: React.FC = () => {
|
|||
toast.error(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 상태 토글 오류:", error);
|
||||
// console.error("메뉴 상태 토글 오류:", error);
|
||||
toast.error(getUITextSync("message.menu.status.toggle.failed"));
|
||||
}
|
||||
};
|
||||
|
|
@ -785,15 +785,15 @@ export const MenuManagement: React.FC = () => {
|
|||
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
|
||||
|
||||
// 디버깅을 위한 간단한 상태 표시
|
||||
console.log("🔍 MenuManagement 렌더링 상태:", {
|
||||
loading,
|
||||
uiTextsLoading,
|
||||
uiTextsCount,
|
||||
adminMenusCount,
|
||||
userMenusCount,
|
||||
selectedMenuType,
|
||||
userLang,
|
||||
});
|
||||
// console.log("🔍 MenuManagement 렌더링 상태:", {
|
||||
// loading,
|
||||
// uiTextsLoading,
|
||||
// uiTextsCount,
|
||||
// adminMenusCount,
|
||||
// userMenusCount,
|
||||
// selectedMenuType,
|
||||
// userLang,
|
||||
// });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -828,7 +828,7 @@ export const MenuManagement: React.FC = () => {
|
|||
<CardContent className="space-y-3 pt-4">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("admin")}
|
||||
>
|
||||
|
|
@ -836,7 +836,7 @@ export const MenuManagement: React.FC = () => {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{getUITextSync("menu.management.admin.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -849,7 +849,7 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedMenuType === "user" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => handleMenuTypeChange("user")}
|
||||
>
|
||||
|
|
@ -857,7 +857,7 @@ export const MenuManagement: React.FC = () => {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{getUITextSync("menu.management.user.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -997,7 +997,7 @@ export const MenuManagement: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1006,7 +1006,7 @@ export const MenuManagement: React.FC = () => {
|
|||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
const getLevelBadge = (level: number) => {
|
||||
switch (level) {
|
||||
case 0:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
return "bg-primary/20 text-blue-800";
|
||||
case 1:
|
||||
return "bg-green-100 text-green-800";
|
||||
case 2:
|
||||
|
|
@ -239,7 +239,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{seq}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`font-medium ${companyName && companyName !== getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
|
||||
|
|
@ -253,12 +253,12 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-left text-sm text-gray-600">
|
||||
<TableCell className="text-left text-sm text-muted-foreground">
|
||||
<div className="max-w-[200px]">
|
||||
{menuUrl ? (
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={`cursor-pointer transition-colors hover:text-blue-600 ${
|
||||
className={`cursor-pointer transition-colors hover:text-primary ${
|
||||
menuUrl.length > 30 ? "truncate" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export default function MonitoringDashboard() {
|
|||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
completed: "bg-green-100 text-green-800",
|
||||
failed: "bg-red-100 text-red-800",
|
||||
running: "bg-blue-100 text-blue-800",
|
||||
failed: "bg-destructive/20 text-red-800",
|
||||
running: "bg-primary/20 text-blue-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
|
|
@ -129,7 +129,7 @@ export default function MonitoringDashboard() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleAutoRefresh}
|
||||
className={autoRefresh ? "bg-blue-50 text-blue-600" : ""}
|
||||
className={autoRefresh ? "bg-accent text-primary" : ""}
|
||||
>
|
||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||
자동 새로고침
|
||||
|
|
@ -167,7 +167,7 @@ export default function MonitoringDashboard() {
|
|||
<div className="text-2xl">🔄</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{monitoring.running_jobs}</div>
|
||||
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 실행 중인 작업
|
||||
</p>
|
||||
|
|
@ -193,7 +193,7 @@ export default function MonitoringDashboard() {
|
|||
<div className="text-2xl">❌</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{monitoring.failed_jobs_today}</div>
|
||||
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
주의가 필요한 작업
|
||||
</p>
|
||||
|
|
@ -269,7 +269,7 @@ export default function MonitoringDashboard() {
|
|||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
{execution.error_message ? (
|
||||
<span className="text-red-600 text-sm truncate block">
|
||||
<span className="text-destructive text-sm truncate block">
|
||||
{execution.error_message}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ export default function MultiLangPage() {
|
|||
// 회사 목록 조회
|
||||
const fetchCompanies = async () => {
|
||||
try {
|
||||
console.log("회사 목록 조회 시작");
|
||||
// console.log("회사 목록 조회 시작");
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
console.log("회사 목록 응답 데이터:", response.data);
|
||||
// console.log("회사 목록 응답 데이터:", response.data);
|
||||
|
||||
const data = response.data;
|
||||
if (data.success) {
|
||||
|
|
@ -74,13 +74,13 @@ export default function MultiLangPage() {
|
|||
code: company.company_code,
|
||||
name: company.company_name,
|
||||
}));
|
||||
console.log("변환된 회사 목록:", companyList);
|
||||
// console.log("변환된 회사 목록:", companyList);
|
||||
setCompanies(companyList);
|
||||
} else {
|
||||
console.error("회사 목록 조회 실패:", data.message);
|
||||
// console.error("회사 목록 조회 실패:", data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
// console.error("회사 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ export default function MultiLangPage() {
|
|||
setLanguages(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 목록 조회 실패:", error);
|
||||
// console.error("언어 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -103,13 +103,13 @@ export default function MultiLangPage() {
|
|||
const response = await apiClient.get("/multilang/keys");
|
||||
const data = response.data;
|
||||
if (data.success) {
|
||||
console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
||||
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
||||
setLangKeys(data.data);
|
||||
} else {
|
||||
console.error("❌ 키 목록 로드 실패:", data.message);
|
||||
// console.error("❌ 키 목록 로드 실패:", data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("다국어 키 목록 조회 실패:", error);
|
||||
// console.error("다국어 키 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -146,25 +146,25 @@ export default function MultiLangPage() {
|
|||
// 선택된 키의 다국어 텍스트 조회
|
||||
const fetchLangTexts = async (keyId: number) => {
|
||||
try {
|
||||
console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
||||
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
||||
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
||||
const data = response.data;
|
||||
console.log("다국어 텍스트 조회 응답:", data);
|
||||
// console.log("다국어 텍스트 조회 응답:", data);
|
||||
if (data.success) {
|
||||
setLangTexts(data.data);
|
||||
// 편집용 텍스트 초기화
|
||||
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
||||
setEditingTexts(editingData);
|
||||
console.log("편집용 텍스트 설정:", editingData);
|
||||
// console.log("편집용 텍스트 설정:", editingData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("다국어 텍스트 조회 실패:", error);
|
||||
// console.error("다국어 텍스트 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 언어 키 선택 처리
|
||||
const handleKeySelect = (key: LangKey) => {
|
||||
console.log("언어 키 선택:", key);
|
||||
// console.log("언어 키 선택:", key);
|
||||
setSelectedKey(key);
|
||||
fetchLangTexts(key.keyId);
|
||||
};
|
||||
|
|
@ -172,9 +172,9 @@ export default function MultiLangPage() {
|
|||
// 디버깅용 useEffect
|
||||
useEffect(() => {
|
||||
if (selectedKey) {
|
||||
console.log("선택된 키 변경:", selectedKey);
|
||||
console.log("언어 목록:", languages);
|
||||
console.log("편집 텍스트:", editingTexts);
|
||||
// console.log("선택된 키 변경:", selectedKey);
|
||||
// console.log("언어 목록:", languages);
|
||||
// console.log("편집 텍스트:", editingTexts);
|
||||
}
|
||||
}, [selectedKey, languages, editingTexts]);
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ export default function MultiLangPage() {
|
|||
fetchLangTexts(selectedKey.keyId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("텍스트 저장 실패:", error);
|
||||
// console.error("텍스트 저장 실패:", error);
|
||||
alert("저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -271,7 +271,7 @@ export default function MultiLangPage() {
|
|||
alert(`오류: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 저장 중 오류:", error);
|
||||
// console.error("언어 저장 중 오류:", error);
|
||||
alert("언어 저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -307,7 +307,7 @@ export default function MultiLangPage() {
|
|||
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 삭제 중 오류:", error);
|
||||
// console.error("언어 삭제 중 오류:", error);
|
||||
alert("언어 삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -369,7 +369,7 @@ export default function MultiLangPage() {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 키 저장 실패:", error);
|
||||
// console.error("언어 키 저장 실패:", error);
|
||||
alert("언어 키 저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -397,7 +397,7 @@ export default function MultiLangPage() {
|
|||
alert("상태 변경 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("키 상태 토글 실패:", error);
|
||||
// console.error("키 상태 토글 실패:", error);
|
||||
alert("키 상태 변경 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -414,7 +414,7 @@ export default function MultiLangPage() {
|
|||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 상태 토글 실패:", error);
|
||||
// console.error("언어 상태 토글 실패:", error);
|
||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -463,7 +463,7 @@ export default function MultiLangPage() {
|
|||
alert("일부 키 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("선택된 키 삭제 실패:", error);
|
||||
// console.error("선택된 키 삭제 실패:", error);
|
||||
alert("선택된 키 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -485,7 +485,7 @@ export default function MultiLangPage() {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 키 삭제 실패:", error);
|
||||
// console.error("언어 키 삭제 실패:", error);
|
||||
alert("언어 키 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -673,7 +673,7 @@ export default function MultiLangPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab("keys")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "keys" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
다국어 키 관리
|
||||
|
|
@ -681,7 +681,7 @@ export default function MultiLangPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab("languages")}
|
||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
activeTab === "languages" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
언어 관리
|
||||
|
|
@ -698,7 +698,7 @@ export default function MultiLangPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
||||
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
||||
<div className="flex space-x-2">
|
||||
{selectedLanguages.size > 0 && (
|
||||
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
||||
|
|
@ -759,13 +759,13 @@ export default function MultiLangPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">검색 결과: {getFilteredLangKeys().length}건</div>
|
||||
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div>
|
||||
<div className="mb-2 text-sm text-gray-600">전체: {getFilteredLangKeys().length}건</div>
|
||||
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={getFilteredLangKeys()}
|
||||
|
|
|
|||
|
|
@ -40,15 +40,15 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
|
||||
// 디버그: 전달받은 메뉴 데이터 확인
|
||||
console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
|
||||
total: menus.length,
|
||||
sample: menus.slice(0, 3),
|
||||
keys: menus.length > 0 ? Object.keys(menus[0]) : [],
|
||||
});
|
||||
// console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
|
||||
// total: menus.length,
|
||||
// sample: menus.slice(0, 3),
|
||||
// keys: menus.length > 0 ? Object.keys(menus[0]) : [],
|
||||
// });
|
||||
|
||||
// 메뉴 선택
|
||||
const handleMenuSelect = async (menuId: string) => {
|
||||
console.log("메뉴 선택:", menuId);
|
||||
// console.log("메뉴 선택:", menuId);
|
||||
setSelectedMenuId(menuId);
|
||||
|
||||
// 다양한 형식의 objid 대응
|
||||
|
|
@ -57,7 +57,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
return objid?.toString() === menuId;
|
||||
});
|
||||
|
||||
console.log("선택된 메뉴:", menu);
|
||||
// console.log("선택된 메뉴:", menu);
|
||||
setSelectedMenu(menu || null);
|
||||
|
||||
if (menu) {
|
||||
|
|
@ -75,7 +75,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
setAssignedScreens(screens);
|
||||
} catch (error) {
|
||||
console.error("할당된 화면 로드 실패:", error);
|
||||
// console.error("할당된 화면 로드 실패:", error);
|
||||
toast.error("할당된 화면 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -94,7 +94,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
|
||||
setAvailableScreens(available);
|
||||
} catch (error) {
|
||||
console.error("사용 가능한 화면 로드 실패:", error);
|
||||
// console.error("사용 가능한 화면 로드 실패:", error);
|
||||
toast.error("사용 가능한 화면 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -118,7 +118,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
setShowAssignDialog(false);
|
||||
setSelectedScreen(null);
|
||||
} catch (error) {
|
||||
console.error("화면 할당 실패:", error);
|
||||
// console.error("화면 할당 실패:", error);
|
||||
toast.error("화면 할당에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -142,7 +142,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
setShowUnassignDialog(false);
|
||||
setSelectedScreen(null);
|
||||
} catch (error) {
|
||||
console.error("화면 할당 해제 실패:", error);
|
||||
// console.error("화면 할당 해제 실패:", error);
|
||||
toast.error("화면 할당 해제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -162,14 +162,14 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
|
||||
// 단순화된 메뉴 옵션 생성 (모든 메뉴를 평면적으로 표시)
|
||||
const getMenuOptions = (menuList: MenuItem[]): JSX.Element[] => {
|
||||
console.log("메뉴 옵션 생성:", {
|
||||
total: menuList.length,
|
||||
sample: menuList.slice(0, 3).map((m) => ({
|
||||
objid: m.objid || m.OBJID || (m as any).objid,
|
||||
name: m.menu_name_kor || m.MENU_NAME_KOR || (m as any).menu_name_kor,
|
||||
parent: m.parent_obj_id || m.PARENT_OBJ_ID || (m as any).parent_obj_id,
|
||||
})),
|
||||
});
|
||||
// console.log("메뉴 옵션 생성:", {
|
||||
// total: menuList.length,
|
||||
// sample: menuList.slice(0, 3).map((m) => ({
|
||||
// objid: m.objid || m.OBJID || (m as any).objid,
|
||||
// name: m.menu_name_kor || m.MENU_NAME_KOR || (m as any).menu_name_kor,
|
||||
// parent: m.parent_obj_id || m.PARENT_OBJ_ID || (m as any).parent_obj_id,
|
||||
// })),
|
||||
// });
|
||||
|
||||
if (!menuList || menuList.length === 0) {
|
||||
return [
|
||||
|
|
@ -188,7 +188,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
// 들여쓰기 (레벨에 따라)
|
||||
const indent = " ".repeat(Math.max(0, lev));
|
||||
|
||||
console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
|
||||
// console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
|
||||
|
||||
return (
|
||||
<SelectItem key={menuObjid?.toString() || `menu-${index}`} value={menuObjid?.toString() || `menu-${index}`}>
|
||||
|
|
@ -231,10 +231,10 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
(selectedMenu as any).menu_name_kor ||
|
||||
"메뉴"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
URL: {selectedMenu.menu_url || selectedMenu.MENU_URL || (selectedMenu as any).menu_url || "없음"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
설명:{" "}
|
||||
{selectedMenu.menu_desc || selectedMenu.MENU_DESC || (selectedMenu as any).menu_desc || "없음"}
|
||||
</p>
|
||||
|
|
@ -294,7 +294,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
테이블: {screen.tableName} | 생성일: {screen.createdDate.toLocaleDateString()}
|
||||
</p>
|
||||
{screen.description && <p className="mt-1 text-sm text-gray-500">{screen.description}</p>}
|
||||
|
|
@ -306,7 +306,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
setSelectedScreen(screen);
|
||||
setShowUnassignDialog(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-destructive hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -347,7 +347,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
<div
|
||||
key={screen.screenId}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selectedScreen?.screenId === screen.screenId ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
|
||||
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setSelectedScreen(screen)}
|
||||
>
|
||||
|
|
@ -357,7 +357,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
|||
{screen.screenCode}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">테이블: {screen.tableName}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">테이블: {screen.tableName}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function SortableCodeItem({
|
|||
"cursor-pointer transition-colors",
|
||||
code.isActive === "Y" || code.is_active === "Y"
|
||||
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
|
||||
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
|
||||
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
|
@ -100,7 +100,7 @@ export function SortableCodeItem({
|
|||
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{code.codeValue || code.code_value}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{code.codeValue || code.code_value}</p>
|
||||
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,32 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
return;
|
||||
}
|
||||
|
||||
// SELECT 쿼리만 허용하는 검증
|
||||
const trimmedQuery = query.trim().toUpperCase();
|
||||
if (!trimmedQuery.startsWith('SELECT')) {
|
||||
toast({
|
||||
title: "보안 오류",
|
||||
description: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다. INSERT, UPDATE, DELETE는 허용되지 않습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 위험한 키워드 검사
|
||||
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE'];
|
||||
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
|
||||
trimmedQuery.includes(keyword)
|
||||
);
|
||||
|
||||
if (hasDangerousKeyword) {
|
||||
toast({
|
||||
title: "보안 오류",
|
||||
description: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("쿼리 실행 시작:", { connectionId, query });
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
case "success":
|
||||
return "text-green-600";
|
||||
case "error":
|
||||
return "text-red-600";
|
||||
return "text-destructive";
|
||||
default:
|
||||
return "text-blue-600";
|
||||
return "text-primary";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
|||
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-600">{message}</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} className="w-20">
|
||||
|
|
@ -398,7 +398,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
|
|||
{/* 중복확인 결과 메시지 */}
|
||||
{duplicateCheckMessage && (
|
||||
<div
|
||||
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-destructive"}`}
|
||||
>
|
||||
{duplicateCheckMessage}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
</div>
|
||||
|
||||
{/* 비밀번호 일치 여부 표시 */}
|
||||
{showMismatchError && <p className="text-sm text-red-600">비밀번호가 일치하지 않습니다.</p>}
|
||||
{showMismatchError && <p className="text-sm text-destructive">비밀번호가 일치하지 않습니다.</p>}
|
||||
{isPasswordMatch && <p className="text-sm text-green-600">비밀번호가 일치합니다.</p>}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ export function UserStatusConfirmDialog({
|
|||
const currentStatusText = USER_STATUS_LABELS[user.status as keyof typeof USER_STATUS_LABELS] || user.status;
|
||||
const newStatusText = USER_STATUS_LABELS[newStatus as keyof typeof USER_STATUS_LABELS] || newStatus;
|
||||
|
||||
const currentStatusColor = user.status === "active" ? "text-blue-600" : "text-gray-600";
|
||||
const newStatusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600";
|
||||
const currentStatusColor = user.status === "active" ? "text-primary" : "text-muted-foreground";
|
||||
const newStatusColor = newStatus === "active" ? "text-primary" : "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
|
|
@ -67,7 +67,7 @@ export function UserStatusConfirmDialog({
|
|||
<Button variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={onConfirm} className={newStatus === "active" ? "bg-blue-500 hover:bg-blue-600" : ""}>
|
||||
<Button onClick={onConfirm} variant="default">
|
||||
변경
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -116,14 +116,6 @@ export function UserToolbar({
|
|||
|
||||
{/* 고급 검색 필드들 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사번</label>
|
||||
<Input
|
||||
placeholder="사번 검색"
|
||||
value={searchFilter.search_sabun || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
<button
|
||||
className="
|
||||
w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-blue-500 hover:text-white
|
||||
text-gray-400 hover:bg-accent0 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
onClick={() => onConfigure(element)}
|
||||
|
|
@ -240,7 +240,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
<button
|
||||
className="
|
||||
element-close w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-red-500 hover:text-white
|
||||
text-gray-400 hover:bg-destructive/100 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
onClick={handleRemove}
|
||||
|
|
@ -259,7 +259,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
{isLoadingData ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
|
||||
<div>
|
||||
<strong>Y축:</strong>{' '}
|
||||
|
|
@ -240,7 +240,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
)}
|
||||
<div><strong>데이터 행 수:</strong> {queryResult.rows.length}개</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="text-blue-600 mt-2">
|
||||
<div className="text-primary mt-2">
|
||||
✨ 다중 시리즈 차트가 생성됩니다!
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -249,7 +249,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
w-full min-h-full relative
|
||||
bg-gray-100
|
||||
bg-grid-pattern
|
||||
${isDragOver ? 'bg-blue-50' : ''}
|
||||
${isDragOver ? 'bg-accent' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: `
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ export default function DashboardDesigner() {
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
|
|
@ -221,7 +221,7 @@ export default function DashboardDesigner() {
|
|||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
||||
{/* 편집 중인 대시보드 표시 */}
|
||||
{dashboardTitle && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-blue-500 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
||||
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
||||
📝 편집 중: {dashboardTitle}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
className="border-l-4 border-primary"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
|
|
|
|||
|
|
@ -70,13 +70,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{element.title} 설정
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
데이터 소스와 차트 설정을 구성하세요
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||
className="text-gray-400 hover:text-muted-foreground text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
@ -89,7 +89,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'query'
|
||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
>
|
||||
|
|
@ -100,7 +100,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'chart'
|
||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
>
|
||||
|
|
@ -147,7 +147,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
className="px-4 py-2 text-muted-foreground border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
@ -155,7 +155,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
onClick={handleSave}
|
||||
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
|
||||
className="
|
||||
px-4 py-2 bg-blue-500 text-white rounded-lg
|
||||
px-4 py-2 bg-accent0 text-white rounded-lg
|
||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ ORDER BY Q4 DESC;`
|
|||
onClick={executeQuery}
|
||||
disabled={isExecuting || !query.trim()}
|
||||
className="
|
||||
px-3 py-1 bg-blue-500 text-white rounded text-sm
|
||||
px-3 py-1 bg-accent0 text-white rounded text-sm
|
||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
flex items-center gap-1
|
||||
"
|
||||
|
|
@ -172,10 +172,10 @@ ORDER BY Q4 DESC;`
|
|||
|
||||
{/* 샘플 쿼리 버튼들 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-600">샘플 쿼리:</span>
|
||||
<span className="text-sm text-muted-foreground">샘플 쿼리:</span>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('comparison')}
|
||||
className="px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 rounded font-medium"
|
||||
className="px-2 py-1 text-xs bg-primary/20 hover:bg-blue-200 rounded font-medium"
|
||||
>
|
||||
🔥 제품 비교
|
||||
</button>
|
||||
|
|
@ -224,7 +224,7 @@ ORDER BY Q4 DESC;`
|
|||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">자동 새로고침:</label>
|
||||
<label className="text-sm text-muted-foreground">자동 새로고침:</label>
|
||||
<select
|
||||
value={dataSource?.refreshInterval || 30000}
|
||||
onChange={(e) => onDataSourceChange({
|
||||
|
|
@ -246,7 +246,7 @@ ORDER BY Q4 DESC;`
|
|||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm font-medium">❌ 오류</div>
|
||||
<div className="text-red-700 text-sm mt-1">{error}</div>
|
||||
</div>
|
||||
|
|
@ -282,7 +282,7 @@ ORDER BY Q4 DESC;`
|
|||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
{queryResult.columns.map((col, colIdx) => (
|
||||
<td key={colIdx} className="py-1 px-2 text-gray-600">
|
||||
<td key={colIdx} className="py-1 px-2 text-muted-foreground">
|
||||
{String(row[col] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { animations, animationCombos, AnimationConfig } from "@/lib/animations/animations";
|
||||
|
||||
interface AnimatedComponentProps {
|
||||
children: React.ReactNode;
|
||||
animation?: keyof typeof animations;
|
||||
combo?: keyof typeof animationCombos;
|
||||
config?: AnimationConfig;
|
||||
trigger?: "mount" | "hover" | "click" | "visible";
|
||||
delay?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const AnimatedComponent: React.FC<AnimatedComponentProps> = ({
|
||||
children,
|
||||
animation = "fadeIn",
|
||||
combo,
|
||||
config = {},
|
||||
trigger = "mount",
|
||||
delay = 0,
|
||||
className = "",
|
||||
style = {},
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger === "mount") {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
setIsAnimating(true);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [trigger, delay]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (trigger === "hover") {
|
||||
setIsAnimating(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === "hover") {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (trigger === "click") {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => setIsAnimating(false), config.duration || 300);
|
||||
}
|
||||
};
|
||||
|
||||
const getAnimationStyle = () => {
|
||||
if (combo) {
|
||||
return animationCombos[combo]();
|
||||
}
|
||||
|
||||
if (animation) {
|
||||
return animations[animation](config);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const animationStyle = isAnimating ? getAnimationStyle() : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
...animationStyle,
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 특화된 애니메이션 컴포넌트들
|
||||
export const FadeIn: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="fadeIn" />
|
||||
);
|
||||
|
||||
export const SlideInFromLeft: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromLeft" />
|
||||
);
|
||||
|
||||
export const SlideInFromRight: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromRight" />
|
||||
);
|
||||
|
||||
export const SlideInFromTop: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromTop" />
|
||||
);
|
||||
|
||||
export const SlideInFromBottom: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromBottom" />
|
||||
);
|
||||
|
||||
export const ScaleIn: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="scaleIn" />
|
||||
);
|
||||
|
||||
export const Bounce: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="bounce" />
|
||||
);
|
||||
|
||||
export const Pulse: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="pulse" />
|
||||
);
|
||||
|
||||
export const Glow: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="glow" />
|
||||
);
|
||||
|
||||
// 조합 애니메이션 컴포넌트들
|
||||
export const PageTransition: React.FC<Omit<AnimatedComponentProps, "combo"> & { direction?: "left" | "right" | "up" | "down" }> = ({ direction, ...props }) => (
|
||||
<AnimatedComponent {...props} combo="pageTransition" />
|
||||
);
|
||||
|
||||
export const ModalEnter: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="modalEnter" />
|
||||
);
|
||||
|
||||
export const ModalExit: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="modalExit" />
|
||||
);
|
||||
|
||||
export const ButtonClick: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="buttonClick" trigger="click" />
|
||||
);
|
||||
|
||||
export const SuccessNotification: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="successNotification" />
|
||||
);
|
||||
|
||||
export const LoadingSpinner: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="loadingSpinner" />
|
||||
);
|
||||
|
||||
export const HoverLift: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="hoverLift" trigger="hover" />
|
||||
);
|
||||
|
|
@ -115,7 +115,7 @@ export function AuthGuard({
|
|||
console.log("AuthGuard: 로딩 중 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-blue-100 p-4">
|
||||
<div className="mb-4 rounded bg-primary/20 p-4">
|
||||
<h3 className="font-bold">AuthGuard 로딩 중...</h3>
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
</div>
|
||||
|
|
@ -129,10 +129,10 @@ export function AuthGuard({
|
|||
console.log("AuthGuard: 인증 실패 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-red-100 p-4">
|
||||
<div className="mb-4 rounded bg-destructive/20 p-4">
|
||||
<h3 className="font-bold">인증 실패</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-red-600">
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -150,7 +150,7 @@ export function AuthGuard({
|
|||
<div className="mb-4 rounded bg-orange-100 p-4">
|
||||
<h3 className="font-bold">관리자 권한 없음</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-red-600">
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ export function ErrorMessage({ message }: ErrorMessageProps) {
|
|||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{message}</div>
|
||||
<div className="my-4 rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-red-700">{message}</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Shield } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { UI_CONFIG } from "@/constants/auth";
|
||||
|
||||
/**
|
||||
|
|
@ -7,10 +7,16 @@ import { UI_CONFIG } from "@/constants/auth";
|
|||
export function LoginHeader() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-xl bg-slate-900 shadow-lg">
|
||||
<Shield className="h-10 w-10 text-white" />
|
||||
<div className="mx-auto mb-2 flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/vexplor.png"
|
||||
alt={UI_CONFIG.COMPANY_NAME}
|
||||
width={180}
|
||||
height={60}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-2 text-3xl font-bold text-slate-900">{UI_CONFIG.COMPANY_NAME}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ export const createStatusColumn = (accessorKey: string, header: string) => ({
|
|||
? "bg-gray-50 text-gray-700"
|
||||
: status === "pending" || status === "대기"
|
||||
? "bg-yellow-50 text-yellow-700"
|
||||
: "bg-red-50 text-red-700",
|
||||
: "bg-destructive/10 text-red-700",
|
||||
)}
|
||||
>
|
||||
{status || "-"}
|
||||
|
|
|
|||
|
|
@ -42,63 +42,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
let maxWidth = 800; // 최소 너비
|
||||
let maxHeight = 600; // 최소 높이
|
||||
// 모든 컴포넌트의 경계 찾기
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length });
|
||||
|
||||
components.forEach((component, index) => {
|
||||
// position과 size는 BaseComponent에서 별도 속성으로 관리
|
||||
components.forEach((component) => {
|
||||
const x = parseFloat(component.position?.x?.toString() || "0");
|
||||
const y = parseFloat(component.position?.y?.toString() || "0");
|
||||
const width = parseFloat(component.size?.width?.toString() || "100");
|
||||
const height = parseFloat(component.size?.height?.toString() || "40");
|
||||
|
||||
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
|
||||
const rightEdge = x + width;
|
||||
const bottomEdge = y + height;
|
||||
|
||||
console.log(
|
||||
`📏 컴포넌트 ${index + 1} (${component.id}): x=${x}, y=${y}, w=${width}, h=${height}, rightEdge=${rightEdge}, bottomEdge=${bottomEdge}`,
|
||||
);
|
||||
|
||||
const newMaxWidth = Math.max(maxWidth, rightEdge + 100); // 여백 증가
|
||||
const newMaxHeight = Math.max(maxHeight, bottomEdge + 100); // 여백 증가
|
||||
|
||||
if (newMaxWidth > maxWidth || newMaxHeight > maxHeight) {
|
||||
console.log(`🔄 크기 업데이트: ${maxWidth}×${maxHeight} → ${newMaxWidth}×${newMaxHeight}`);
|
||||
maxWidth = newMaxWidth;
|
||||
maxHeight = newMaxHeight;
|
||||
}
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + width);
|
||||
maxY = Math.max(maxY, y + height);
|
||||
});
|
||||
|
||||
console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight });
|
||||
// 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px)
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const padding = 128; // 좌우 또는 상하 합계 여백
|
||||
|
||||
// 브라우저 크기 제한 확인 (더욱 관대하게 설정)
|
||||
const maxAllowedWidth = window.innerWidth * 0.98; // 95% -> 98%
|
||||
const maxAllowedHeight = window.innerHeight * 0.95; // 90% -> 95%
|
||||
const finalWidth = Math.max(contentWidth + padding, 400); // 최소 400px
|
||||
const finalHeight = Math.max(contentHeight + padding, 300); // 최소 300px
|
||||
|
||||
console.log("📐 크기 제한 정보:", {
|
||||
계산된크기: { maxWidth, maxHeight },
|
||||
브라우저제한: { maxAllowedWidth, maxAllowedHeight },
|
||||
브라우저크기: { width: window.innerWidth, height: window.innerHeight },
|
||||
});
|
||||
|
||||
// 컴포넌트 기반 크기를 우선 적용하되, 브라우저 제한을 고려
|
||||
const finalDimensions = {
|
||||
width: Math.min(maxWidth, maxAllowedWidth),
|
||||
height: Math.min(maxHeight, maxAllowedHeight),
|
||||
return {
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.98),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.95),
|
||||
};
|
||||
|
||||
console.log("✅ 최종 화면 크기:", finalDimensions);
|
||||
console.log("🔧 크기 적용 분석:", {
|
||||
width적용: maxWidth <= maxAllowedWidth ? "컴포넌트기준" : "브라우저제한",
|
||||
height적용: maxHeight <= maxAllowedHeight ? "컴포넌트기준" : "브라우저제한",
|
||||
컴포넌트크기: { maxWidth, maxHeight },
|
||||
최종크기: finalDimensions,
|
||||
});
|
||||
|
||||
return finalDimensions;
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
|
|
@ -113,10 +86,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
console.log("🚪 ScreenModal 닫기 이벤트 수신");
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
title: "",
|
||||
size: "md",
|
||||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
window.addEventListener("openScreenModal", handleOpenModal as EventListener);
|
||||
window.addEventListener("closeSaveModal", handleCloseModal);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("openScreenModal", handleOpenModal as EventListener);
|
||||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -190,17 +177,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
}
|
||||
|
||||
// 헤더 높이와 패딩을 고려한 전체 높이 계산 (실제 측정값 기반)
|
||||
const headerHeight = 80; // DialogHeader + 패딩 (더 정확한 값)
|
||||
// 헤더 높이만 고려 (패딩 제거)
|
||||
const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함)
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용
|
||||
maxWidth: "98vw", // 안전장치
|
||||
maxHeight: "95vh", // 안전장치
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, // 화면 크기 그대로
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 헤더 + 화면 높이
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -215,12 +202,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<p className="text-gray-600">화면을 불러오는 중...</p>
|
||||
<p className="text-muted-foreground">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
|
|
@ -229,6 +216,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
height: screenDimensions?.height || 600,
|
||||
transformOrigin: 'center center',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => (
|
||||
|
|
@ -258,7 +248,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-gray-600">화면 데이터가 없습니다.</p>
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,6 @@ export function ValidationMessage({ message, isValid, isLoading, className }: Va
|
|||
}
|
||||
|
||||
return (
|
||||
<p className={cn("text-sm transition-colors", isValid ? "text-green-600" : "text-red-600", className)}>{message}</p>
|
||||
<p className={cn("text-sm transition-colors", isValid ? "text-green-600" : "text-destructive", className)}>{message}</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,10 +105,10 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
return (
|
||||
<div className="relative w-full h-full bg-gray-100 overflow-auto">
|
||||
{/* 새로고침 상태 표시 */}
|
||||
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600">
|
||||
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-muted-foreground">
|
||||
마지막 업데이트: {lastRefresh.toLocaleTimeString()}
|
||||
{Array.from(loadingElements).length > 0 && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
<span className="ml-2 text-primary">
|
||||
({Array.from(loadingElements).length}개 로딩 중...)
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -164,7 +164,7 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||
className="text-gray-400 hover:text-muted-foreground disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -203,8 +203,8 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-sm text-gray-600">업데이트 중...</div>
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">업데이트 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(diagram.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
|
|
@ -269,7 +269,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-red-600">
|
||||
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -302,7 +302,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
|
|||
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
|
||||
className="w-full rounded-lg bg-destructive/100 p-3 font-medium text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
🗑️ 전체 삭제
|
||||
</button>
|
||||
|
|
@ -72,7 +72,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
|
|||
{/* 통계 정보 */}
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-700">통계</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>테이블 노드:</span>
|
||||
<span className="font-medium">{nodes.length}개</span>
|
||||
|
|
|
|||
|
|
@ -85,11 +85,11 @@ export const EdgeInfoPanel: React.FC<EdgeInfoPanelProps> = ({
|
|||
|
||||
{/* 관계 화살표 */}
|
||||
<div className="flex justify-center">
|
||||
<span className="text-l text-gray-600">→</span>
|
||||
<span className="text-l text-muted-foreground">→</span>
|
||||
</div>
|
||||
|
||||
{/* To 테이블 */}
|
||||
<div className="rounded-lg border-l-4 border-blue-400 bg-blue-50 p-3">
|
||||
<div className="rounded-lg border-l-4 border-blue-400 bg-accent p-3">
|
||||
<div className="mb-2 text-xs font-bold tracking-wide text-blue-700 uppercase">TO</div>
|
||||
<div className="mb-2 text-base font-bold text-gray-800">{edgeInfo.toTable}</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -97,7 +97,7 @@ export const EdgeInfoPanel: React.FC<EdgeInfoPanelProps> = ({
|
|||
{edgeInfo.toColumns.map((column, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
|
||||
className="inline-flex items-center rounded-md bg-primary/20 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
|
||||
>
|
||||
{column}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -134,18 +134,18 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-4 right-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
|
||||
<div className="pointer-events-auto absolute top-4 right-4 z-40 w-80 rounded-xl border border-primary/20 bg-white shadow-lg">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-full bg-blue-100 p-1">
|
||||
<span className="text-sm text-blue-600">🔗</span>
|
||||
<div className="rounded-full bg-primary/20 p-1">
|
||||
<span className="text-sm text-primary">🔗</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-800">테이블 간 관계 목록</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-muted-foreground"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -159,7 +159,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
|||
{relationships.map((relationship) => (
|
||||
<div
|
||||
key={relationship.id}
|
||||
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
|
||||
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-accent"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
|
|
@ -172,7 +172,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
|||
e.stopPropagation();
|
||||
handleEdit(relationship);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-blue-100 hover:text-blue-600"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-primary/20 hover:text-primary"
|
||||
title="관계 편집"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -190,7 +190,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
|||
e.stopPropagation();
|
||||
handleDelete(relationship);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-destructive/20 hover:text-destructive"
|
||||
title="관계 삭제"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -204,7 +204,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>타입: {relationship.connectionType}</p>
|
||||
<p>From: {relationship.fromTable}</p>
|
||||
<p>To: {relationship.toTable}</p>
|
||||
|
|
|
|||
|
|
@ -149,26 +149,26 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
onKeyPress={handleKeyPress}
|
||||
placeholder="예: 사용자-부서 관계도"
|
||||
disabled={isLoading}
|
||||
className={nameError ? "border-red-500 focus:border-red-500" : ""}
|
||||
className={nameError ? "border-destructive focus:border-destructive" : ""}
|
||||
/>
|
||||
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
|
||||
{nameError && <p className="text-sm text-destructive">{nameError}</p>}
|
||||
</div>
|
||||
|
||||
{/* 관계 요약 정보 */}
|
||||
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
|
||||
<div className="text-sm text-gray-600">관계 수</div>
|
||||
<div className="text-2xl font-bold text-primary">{relationships.length}</div>
|
||||
<div className="text-sm text-muted-foreground">관계 수</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
|
||||
<div className="text-sm text-gray-600">연결된 테이블</div>
|
||||
<div className="text-sm text-muted-foreground">연결된 테이블</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">연결된 컬럼</div>
|
||||
<div className="text-sm text-muted-foreground">연결된 컬럼</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
{relationship.relationshipName || `${relationship.fromTable} → ${relationship.toTable}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{relationship.fromTable} → {relationship.toTable}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
canCreateConnection,
|
||||
}) => {
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
|
||||
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-primary/20 bg-white shadow-lg">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm text-blue-600">📋</span>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20">
|
||||
<span className="text-sm text-primary">📋</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">선택된 테이블</div>
|
||||
|
|
@ -44,7 +44,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-muted-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -66,8 +66,8 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
index === 0
|
||||
? "border-l-4 border-emerald-400 bg-emerald-50"
|
||||
: index === 1
|
||||
? "border-l-4 border-blue-400 bg-blue-50"
|
||||
: "bg-gray-50"
|
||||
? "border-l-4 border-blue-400 bg-accent"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
|
|
@ -88,7 +88,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{tableName}</div>
|
||||
<div className="text-xs text-muted-foreground">{tableName}</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 화살표 (마지막이 아닌 경우) */}
|
||||
|
|
@ -110,7 +110,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
disabled={!canCreateConnection}
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
|
||||
canCreateConnection
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
? "bg-accent0 text-white hover:bg-blue-600"
|
||||
: "cursor-not-allowed bg-gray-300 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -119,7 +119,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
|||
</button>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-600 hover:bg-gray-300"
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-gray-300"
|
||||
>
|
||||
<span>🗑️</span>
|
||||
<span>초기화</span>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
<div
|
||||
key={columnKey}
|
||||
className={`relative cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
|
||||
isSelected ? "bg-blue-100 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
|
||||
isSelected ? "bg-primary/20 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => onColumnClick(table.tableName, columnKey)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
|
|||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
|
||||
{error && <div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{/* 테이블 목록 */}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
|
|
@ -114,7 +114,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
|
|||
<Card
|
||||
key={table.tableName}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
isSelected ? "cursor-not-allowed border-blue-500 bg-blue-50 opacity-60" : "hover:border-gray-300"
|
||||
isSelected ? "cursor-not-allowed border-primary bg-accent opacity-60" : "hover:border-gray-300"
|
||||
}`}
|
||||
onDoubleClick={() => !isSelected && handleAddTable(table)}
|
||||
>
|
||||
|
|
@ -126,10 +126,10 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
|
|||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="font-mono">{table.tableName}</span>
|
||||
{isSelected && <span className="font-medium text-blue-600">(추가됨)</span>}
|
||||
{isSelected && <span className="font-medium text-primary">(추가됨)</span>}
|
||||
</div>
|
||||
|
||||
{table.description && <p className="line-clamp-2 text-xs text-gray-500">{table.description}</p>}
|
||||
|
|
@ -142,7 +142,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
|
|||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="rounded-lg bg-gray-50 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>전체 테이블: {tables.length}개</span>
|
||||
{searchTerm && <span>검색 결과: {filteredTables.length}개</span>}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
|||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -92,11 +92,11 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
|||
)}
|
||||
{/* 그룹 레벨에 따른 들여쓰기 */}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-accent/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">(</span>
|
||||
<span className="text-xs text-blue-600">그룹 시작</span>
|
||||
<span className="font-mono text-sm text-primary">(</span>
|
||||
<span className="text-xs text-primary">그룹 시작</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -110,11 +110,11 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
|||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-accent/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">)</span>
|
||||
<span className="text-xs text-blue-600">그룹 끝</span>
|
||||
<span className="font-mono text-sm text-primary">)</span>
|
||||
<span className="text-xs text-primary">그룹 끝</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -126,7 +126,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
|||
value={conditions[index + 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index + 1, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -150,7 +150,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
|||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
|
|||
multiple={detailSettings.multiple as boolean}
|
||||
/>
|
||||
{value && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>선택된 파일: {value}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,24 +84,24 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
|||
<summary
|
||||
className={`flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium hover:bg-gray-50 hover:text-gray-900 ${
|
||||
isConditionRequired && !hasValidConditions
|
||||
? "border-red-300 bg-red-50 text-red-700"
|
||||
? "border-red-300 bg-destructive/10 text-red-700"
|
||||
: "border-gray-200 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
🔍 이 액션의 실행 조건
|
||||
{isConditionRequired ? (
|
||||
<span className="rounded bg-red-100 px-1 py-0.5 text-xs font-semibold text-red-700">필수</span>
|
||||
<span className="rounded bg-destructive/20 px-1 py-0.5 text-xs font-semibold text-red-700">필수</span>
|
||||
) : (
|
||||
<span className="text-gray-500">(선택사항)</span>
|
||||
)}
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs text-blue-700">
|
||||
{action.conditions.length}개
|
||||
</span>
|
||||
)}
|
||||
{isConditionRequired && !hasValidConditions && (
|
||||
<span className="rounded bg-red-100 px-1 py-0.5 text-xs text-red-600">⚠️ 조건 필요</span>
|
||||
<span className="rounded bg-destructive/20 px-1 py-0.5 text-xs text-destructive">⚠️ 조건 필요</span>
|
||||
)}
|
||||
</div>
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
|
|
@ -151,8 +151,8 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
|
|||
<div
|
||||
className={`rounded border p-3 text-xs ${
|
||||
isConditionRequired
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600"
|
||||
? "border-destructive/20 bg-destructive/10 text-red-700"
|
||||
: "border-gray-200 bg-gray-50 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isConditionRequired ? (
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
|||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<span className="text-xs text-red-600">(필수)</span>
|
||||
<span className="text-xs text-destructive">(필수)</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-2 w-2" />
|
||||
|
|
@ -244,7 +244,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
|||
{/* 컴팩트한 매핑 표시 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{/* 소스 */}
|
||||
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
|
||||
<div className="flex items-center gap-1 rounded bg-accent px-2 py-1">
|
||||
<Select
|
||||
value={mapping.sourceTable || "__EMPTY__"}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -277,7 +277,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
|||
updateFieldMapping(mappingIndex, "sourceTable", "");
|
||||
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||
}}
|
||||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-muted-foreground"
|
||||
title="소스 테이블 지우기"
|
||||
>
|
||||
×
|
||||
|
|
@ -390,7 +390,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
|||
|
||||
{/* 필드 매핑이 없을 때 안내 메시지 */}
|
||||
{action.fieldMappings.length === 0 && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-3 text-xs text-red-700">
|
||||
<div className="rounded border border-destructive/20 bg-destructive/10 p-3 text-xs text-red-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-red-500">⚠️</span>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
|||
: isMapped
|
||||
? "bg-gray-100 text-gray-700"
|
||||
: oppositeSelectedColumn && !isTypeCompatible
|
||||
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
|
||||
? "cursor-not-allowed bg-destructive/10 text-red-400 opacity-60"
|
||||
: isClickable
|
||||
? "cursor-pointer hover:bg-gray-50"
|
||||
: "cursor-not-allowed bg-gray-100 text-gray-400"
|
||||
|
|
@ -250,7 +250,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
|||
: hasDefaultValue
|
||||
? "bg-gray-100"
|
||||
: oppositeSelectedColumn && !isTypeCompatible
|
||||
? "bg-red-50 opacity-60"
|
||||
? "bg-destructive/10 opacity-60"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -292,7 +292,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
|||
|
||||
{isMapped && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="truncate text-xs text-blue-600">← {mapping.fromColumnName}</span>
|
||||
<span className="truncate text-xs text-primary">← {mapping.fromColumnName}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -327,7 +327,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 하단 통계 */}
|
||||
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
|||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||
config.connectionType === "simple-key"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
? "border-primary bg-accent"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onConfigChange({ ...config, connectionType: "simple-key" })}
|
||||
>
|
||||
<Key className="mx-auto h-6 w-6 text-blue-500" />
|
||||
<div className="mt-1 text-xs font-medium">단순 키값 연결</div>
|
||||
<div className="text-xs text-gray-600">중계 테이블 생성</div>
|
||||
<div className="text-xs text-muted-foreground">중계 테이블 생성</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -38,7 +38,7 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
|||
>
|
||||
<Save className="mx-auto h-6 w-6 text-green-500" />
|
||||
<div className="mt-1 text-xs font-medium">데이터 저장</div>
|
||||
<div className="text-xs text-gray-600">필드 매핑 저장</div>
|
||||
<div className="text-xs text-muted-foreground">필드 매핑 저장</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -54,7 +54,7 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
|||
>
|
||||
<Globe className="mx-auto h-6 w-6 text-orange-500" />
|
||||
<div className="mt-1 text-xs font-medium">외부 호출</div>
|
||||
<div className="text-xs text-gray-600">API/이메일 호출</div>
|
||||
<div className="text-xs text-muted-foreground">API/이메일 호출</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
|
|||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">
|
||||
<span className="text-red-600">!=</span>
|
||||
<span className="text-destructive">!=</span>
|
||||
</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
|
|
@ -308,11 +308,11 @@ export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
|
|||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
<SelectItem value="IN">IN</SelectItem>
|
||||
<SelectItem value="NOT IN">
|
||||
<span className="text-red-600">NOT IN</span>
|
||||
<span className="text-destructive">NOT IN</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="EXISTS">EXISTS</SelectItem>
|
||||
<SelectItem value="NOT EXISTS">
|
||||
<span className="text-red-600">NOT EXISTS</span>
|
||||
<span className="text-destructive">NOT EXISTS</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -446,9 +446,9 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
|||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800">매핑 진행 상황</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 {toTableColumns.length}개 컬럼 중{" "}
|
||||
<span className="font-bold text-blue-600">
|
||||
<span className="font-bold text-primary">
|
||||
{columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length}
|
||||
개
|
||||
</span>{" "}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
{/* 현재 선택된 테이블 표시 */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">From 테이블</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">From 테이블</Label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
|
||||
|
|
@ -54,7 +54,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">To 테이블</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">To 테이블</Label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
|
||||
|
|
@ -67,7 +67,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
{/* 컬럼 선택 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">From 컬럼</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">From 컬럼</Label>
|
||||
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||
{fromTableColumns.map((column) => (
|
||||
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||
|
|
@ -100,7 +100,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">To 컬럼</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">To 컬럼</Label>
|
||||
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||
{toTableColumns.map((column) => (
|
||||
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||
|
|
@ -137,7 +137,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">선택된 From 컬럼</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">선택된 From 컬럼</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{selectedFromColumns.length > 0 ? (
|
||||
selectedFromColumns.map((column) => {
|
||||
|
|
@ -156,7 +156,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">선택된 To 컬럼</Label>
|
||||
<Label className="text-xs font-medium text-muted-foreground">선택된 To 컬럼</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{selectedToColumns.length > 0 ? (
|
||||
selectedToColumns.map((column) => {
|
||||
|
|
@ -178,7 +178,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 단순 키값 연결 설정 */}
|
||||
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
|
||||
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-accent/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">단순 키값 연결 설정</span>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { ConnectionTypeSelector } from "./LeftPanel/ConnectionTypeSelector";
|
||||
import { MappingInfoPanel } from "./LeftPanel/MappingInfoPanel";
|
||||
import { StepProgress } from "./RightPanel/StepProgress";
|
||||
import { ConnectionStep } from "./RightPanel/ConnectionStep";
|
||||
import { TableStep } from "./RightPanel/TableStep";
|
||||
import { FieldMappingStep } from "./RightPanel/FieldMappingStep";
|
||||
import { DataConnectionState } from "./types/redesigned";
|
||||
import { ResponsiveContainer, ResponsiveGrid } from "@/components/layout/ResponsiveContainer";
|
||||
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||||
|
||||
// API import
|
||||
import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
DataConnectionState,
|
||||
DataConnectionActions,
|
||||
DataConnectionDesignerProps,
|
||||
FieldMapping,
|
||||
ValidationResult,
|
||||
TestResult,
|
||||
MappingStats,
|
||||
ActionGroup,
|
||||
SingleAction,
|
||||
} from "./types/redesigned";
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import LeftPanel from "./LeftPanel/LeftPanel";
|
||||
import RightPanel from "./RightPanel/RightPanel";
|
||||
|
||||
/**
|
||||
* 🎨 데이터 연결 설정 메인 디자이너
|
||||
* - 좌우 분할 레이아웃 (30% + 70%)
|
||||
* - 상태 관리 및 액션 처리
|
||||
* - 기존 모달 기능을 메인 화면으로 통합
|
||||
*/
|
||||
const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||
onClose,
|
||||
initialData,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
// 🔄 상태 관리
|
||||
const [state, setState] = useState<DataConnectionState>(() => ({
|
||||
const initialState: DataConnectionState = {
|
||||
connectionType: "data_save",
|
||||
currentStep: 1,
|
||||
fieldMappings: [],
|
||||
|
|
@ -52,34 +23,6 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
estimatedRows: 0,
|
||||
actionType: "INSERT",
|
||||
},
|
||||
// 제어 실행 조건 초기값
|
||||
controlConditions: [],
|
||||
|
||||
// 액션 그룹 초기값 (멀티 액션)
|
||||
actionGroups: [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
groupsLogicalOperator: "AND" as "AND" | "OR",
|
||||
|
||||
// 기존 호환성 필드들 (deprecated)
|
||||
actionType: "insert",
|
||||
actionConditions: [],
|
||||
actionFieldMappings: [],
|
||||
isLoading: false,
|
||||
validationErrors: [],
|
||||
|
||||
|
|
@ -777,23 +720,30 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
{/* 상단 네비게이션 */}
|
||||
{showBackButton && (
|
||||
<div className="flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={onClose} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">🔗 데이터 연결 설정</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"} 연결 설정
|
||||
<div className="h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
🎨 제어관리 - 데이터 연결 설정
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
시각적 필드 매핑으로 데이터 연결을 쉽게 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-80px)]">
|
||||
<div className="w-[30%] bg-white border-r border-gray-200 flex flex-col">
|
||||
<ConnectionTypeSelector
|
||||
connectionType={state.connectionType}
|
||||
onConnectionTypeChange={(type) => setState(prev => ({ ...prev, connectionType: type }))}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MappingInfoPanel
|
||||
mappingStats={state.mappingStats}
|
||||
fieldMappings={state.fieldMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onMappingSelect={(mappingId) => setState(prev => ({ ...prev, selectedMapping: mappingId }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
|
|||
<>
|
||||
{/* 트랜잭션 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🔄 트랜잭션 설정</h4>
|
||||
<h4 className="text-xs font-medium text-muted-foreground">🔄 트랜잭션 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs text-gray-500">
|
||||
|
|
@ -98,7 +98,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
|
|||
<>
|
||||
{/* API 호출 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🌐 API 호출 설정</h4>
|
||||
<h4 className="text-xs font-medium text-muted-foreground">🌐 API 호출 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs text-gray-500">
|
||||
|
|
@ -131,7 +131,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
|
|||
|
||||
{/* 로깅 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">📝 로깅 설정</h4>
|
||||
<h4 className="text-xs font-medium text-muted-foreground">📝 로깅 설정</h4>
|
||||
<div>
|
||||
<Select value={settings.logLevel} onValueChange={(value) => handleSettingChange("logLevel", value)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
|
|
|
|||
|
|
@ -1,62 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
import { ConnectionType } from "../types/redesigned";
|
||||
|
||||
// 타입 import
|
||||
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
|
||||
interface ConnectionTypeSelectorProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
onConnectionTypeChange: (type: "data_save" | "external_call") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔘 연결 타입 선택 컴포넌트
|
||||
* - 데이터 저장 (INSERT/UPDATE/DELETE)
|
||||
* - 외부 호출 (API/Webhook)
|
||||
*/
|
||||
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="w-6 h-6" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
||||
connectionType,
|
||||
onConnectionTypeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<RadioGroup
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
|
||||
onTypeChange(value as "data_save" | "external_call");
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{connectionTypes.map((type) => (
|
||||
<div key={type.id} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
연결 타입 선택
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{connectionTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
connectionType === type.id
|
||||
? "border-orange-500 bg-orange-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-orange-300 hover:bg-orange-25"
|
||||
}`}
|
||||
onClick={() => onConnectionTypeChange(type.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
connectionType === type.id
|
||||
? "bg-orange-100 text-orange-600"
|
||||
: "bg-gray-100 text-muted-foreground"
|
||||
}`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{type.label}</h3>
|
||||
<p className="text-sm text-muted-foreground">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionTypeSelector;
|
||||
|
|
|
|||
|
|
@ -88,9 +88,9 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
|
|||
{state.connectionType === "external_call" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="rounded-md bg-blue-50 p-3">
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<h3 className="mb-1 text-sm font-medium text-blue-800">외부 호출 모드</h3>
|
||||
<p className="text-xs text-blue-600">우측 패널에서 REST API 설정을 구성하세요.</p>
|
||||
<p className="text-xs text-primary">우측 패널에서 REST API 설정을 구성하세요.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue