Merge pull request 'lhj' (#84) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/84
This commit is contained in:
commit
c4b92f0710
|
|
@ -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": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.7.4",
|
||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
|
|
@ -36,13 +38,15 @@
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/imap": "^0.8.42",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^1.4.13",
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.20",
|
||||||
"@types/oracledb": "^6.9.1",
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
|
|
@ -2312,6 +2316,19 @@
|
||||||
"@redis/client": "^1.0.0"
|
"@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": {
|
"node_modules/@sideway/address": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||||
|
|
@ -3184,6 +3201,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
|
|
@ -3250,6 +3277,30 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
|
|
@ -3319,9 +3370,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.19",
|
"version": "6.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
||||||
"integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==",
|
"integrity": "sha512-uj83z0GqwqMUE6RI4EKptPlav0FYE6vpIlqJAnxzu+/sSezRdbH69rSBCMsdW6DdsCAzoFQZ52c2UIlhRVQYDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -4868,7 +4919,6 @@
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -5022,7 +5072,6 @@
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0",
|
"domelementtype": "^2.3.0",
|
||||||
|
|
@ -5037,7 +5086,6 @@
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5050,7 +5098,6 @@
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^2.3.0"
|
"domelementtype": "^2.3.0"
|
||||||
|
|
@ -5066,7 +5113,6 @@
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom-serializer": "^2.0.0",
|
"dom-serializer": "^2.0.0",
|
||||||
|
|
@ -5160,11 +5206,19 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
|
|
@ -6198,6 +6252,15 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/helmet": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
||||||
|
|
@ -6214,11 +6277,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
{
|
{
|
||||||
|
|
@ -6335,6 +6413,42 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -7403,6 +7517,15 @@
|
||||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
|
|
@ -7427,6 +7550,42 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
|
|
@ -7434,6 +7593,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -7554,6 +7722,56 @@
|
||||||
"url": "https://github.com/sponsors/wellwelwel"
|
"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": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
|
@ -8202,6 +8420,19 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -8264,6 +8495,15 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/pg": {
|
||||||
"version": "8.16.3",
|
"version": "8.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
|
@ -8610,6 +8850,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
|
@ -8924,6 +9173,18 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
|
|
@ -9525,6 +9786,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
|
|
@ -9771,6 +10041,12 @@
|
||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
|
|
@ -9848,6 +10124,23 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,15 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.7.4",
|
||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
|
|
@ -50,13 +52,15 @@
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/imap": "^0.8.42",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^1.4.13",
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.20",
|
||||||
"@types/oracledb": "^6.9.1",
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
import layoutRoutes from "./routes/layoutRoutes";
|
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 dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
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/template-standards", templateStandardRoutes);
|
||||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||||
app.use("/api/layouts", layoutRoutes);
|
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/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
// TODO: 실제 SMTP 연결 테스트 구현
|
||||||
|
// const account = await mailAccountFileService.getAccountById(id);
|
||||||
|
// nodemailer로 연결 테스트
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,96 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||||
|
|
||||||
|
export class MailSendSimpleController {
|
||||||
|
/**
|
||||||
|
* 메일 발송 (단건 또는 소규모)
|
||||||
|
*/
|
||||||
|
async sendMail(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
||||||
|
|
||||||
|
// 필수 파라미터 검증
|
||||||
|
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||||
|
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,14 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailAccountFileController } from '../controllers/mailAccountFileController';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
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,27 @@
|
||||||
|
/**
|
||||||
|
* 메일 수신 라우트 (Step 2 - 기본 구현)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { MailReceiveBasicController } from '../controllers/mailReceiveBasicController';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
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,13 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 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,18 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 템플릿 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(
|
private async executeDeleteAction(
|
||||||
action: ControlAction,
|
action: ControlAction,
|
||||||
sourceData: Record<string, any>
|
sourceData: Record<string, any>
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
console.log(`🗑️ DELETE 액션 실행 시작:`, {
|
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||||
actionName: action.name,
|
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||||
conditions: action.conditions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE는 조건이 필수
|
|
||||||
if (!action.conditions || action.conditions.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
"DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
|
@ -964,7 +955,7 @@ export class DataflowControlService {
|
||||||
condition.value !== undefined
|
condition.value !== undefined
|
||||||
) {
|
) {
|
||||||
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
|
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
|
||||||
const parts = condition.field.split(".");
|
const parts = condition.field!.split(".");
|
||||||
let tableName: string;
|
let tableName: string;
|
||||||
let fieldName: string;
|
let fieldName: string;
|
||||||
|
|
||||||
|
|
@ -982,7 +973,7 @@ export class DataflowControlService {
|
||||||
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
|
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fieldName = condition.field;
|
fieldName = condition.field!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableGroups.has(tableName)) {
|
if (!tableGroups.has(tableName)) {
|
||||||
|
|
@ -1044,14 +1035,14 @@ export class DataflowControlService {
|
||||||
targetTable: tableName,
|
targetTable: tableName,
|
||||||
whereClause,
|
whereClause,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(`❌ DELETE 실패:`, {
|
console.error(`❌ DELETE 실패:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
error: error,
|
error: error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userFriendlyMessage =
|
const userFriendlyMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? (error as Error).message : String(error);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
message: `DELETE 실패: ${tableName}`,
|
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[] = []
|
params: any[] = []
|
||||||
): Promise<ApiResponse<any[]>> {
|
): Promise<ApiResponse<any[]>> {
|
||||||
try {
|
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 });
|
console.log("연결 정보 조회 시작:", { id });
|
||||||
const connection = await queryOne<any>(
|
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,213 @@
|
||||||
|
/**
|
||||||
|
* 간단한 메일 발송 서비스 (쿼리 제외)
|
||||||
|
* Nodemailer를 사용한 직접 발송
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { mailAccountFileService } from './mailAccountFileService';
|
||||||
|
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||||
|
|
||||||
|
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. SMTP 연결 생성
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: account.smtpHost,
|
||||||
|
port: account.smtpPort,
|
||||||
|
secure: account.smtpSecure, // SSL/TLS
|
||||||
|
auth: {
|
||||||
|
user: account.smtpUsername,
|
||||||
|
pass: account.smtpPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 메일 발송
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${account.name}" <${account.email}>`,
|
||||||
|
to: request.to.join(', '),
|
||||||
|
subject: this.replaceVariables(request.subject, request.variables),
|
||||||
|
html: htmlContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId,
|
||||||
|
accepted: info.accepted as string[],
|
||||||
|
rejected: info.rejected as string[],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
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 {
|
||||||
|
const account = await mailAccountFileService.getAccountById(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('계정을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: account.smtpHost,
|
||||||
|
port: account.smtpPort,
|
||||||
|
secure: account.smtpSecure,
|
||||||
|
auth: {
|
||||||
|
user: account.smtpUsername,
|
||||||
|
pass: account.smtpPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.verify();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'SMTP 연결 성공!',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
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
|
|
@ -18,6 +18,7 @@ services:
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
|
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
"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,
|
||||||
|
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('상태 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</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,283 @@
|
||||||
|
"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";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 계정 수
|
||||||
|
const accountsRes = await fetch('/api/mail/accounts');
|
||||||
|
const accountsData = await accountsRes.json();
|
||||||
|
|
||||||
|
// 템플릿 수
|
||||||
|
const templatesRes = await fetch('/api/mail/templates-file');
|
||||||
|
const templatesData = await templatesRes.json();
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
totalAccounts: accountsData.success ? accountsData.data.length : 0,
|
||||||
|
totalTemplates: templatesData.success ? templatesData.data.length : 0,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -103,7 +103,7 @@ export default function TableManagementPage() {
|
||||||
setUiTexts(response.data.data);
|
setUiTexts(response.data.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("다국어 텍스트 로드 실패:", error);
|
// console.error("다국어 텍스트 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -125,20 +125,20 @@ export default function TableManagementPage() {
|
||||||
// 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵
|
// 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵
|
||||||
const existingColumns = referenceTableColumns[tableName];
|
const existingColumns = referenceTableColumns[tableName];
|
||||||
if (existingColumns && existingColumns.length > 0) {
|
if (existingColumns && existingColumns.length > 0) {
|
||||||
console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
|
// console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
|
// console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
|
||||||
try {
|
try {
|
||||||
const result = await entityJoinApi.getReferenceTableColumns(tableName);
|
const result = await entityJoinApi.getReferenceTableColumns(tableName);
|
||||||
console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
|
// console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
|
||||||
setReferenceTableColumns((prev) => ({
|
setReferenceTableColumns((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[tableName]: result.columns,
|
[tableName]: result.columns,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
|
// console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
|
||||||
setReferenceTableColumns((prev) => ({
|
setReferenceTableColumns((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[tableName]: [],
|
[tableName]: [],
|
||||||
|
|
@ -177,24 +177,24 @@ export default function TableManagementPage() {
|
||||||
const loadCommonCodeCategories = async () => {
|
const loadCommonCodeCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await commonCodeApi.categories.getList({ isActive: true });
|
const response = await commonCodeApi.categories.getList({ isActive: true });
|
||||||
console.log("🔍 공통코드 카테고리 API 응답:", response);
|
// console.log("🔍 공통코드 카테고리 API 응답:", response);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
console.log("📋 공통코드 카테고리 데이터:", response.data);
|
// console.log("📋 공통코드 카테고리 데이터:", response.data);
|
||||||
|
|
||||||
const categories = response.data.map((category) => {
|
const categories = response.data.map((category) => {
|
||||||
console.log("🏷️ 카테고리 항목:", category);
|
// console.log("🏷️ 카테고리 항목:", category);
|
||||||
return {
|
return {
|
||||||
value: category.category_code,
|
value: category.category_code,
|
||||||
label: category.category_name || category.category_code,
|
label: category.category_name || category.category_code,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 매핑된 카테고리 옵션:", categories);
|
// console.log("✅ 매핑된 카테고리 옵션:", categories);
|
||||||
setCommonCodeCategories(categories);
|
setCommonCodeCategories(categories);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("공통코드 카테고리 로드 실패:", error);
|
// console.error("공통코드 카테고리 로드 실패:", error);
|
||||||
// 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능)
|
// 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -213,7 +213,7 @@ export default function TableManagementPage() {
|
||||||
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
|
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 로드 실패:", error);
|
// console.error("테이블 목록 로드 실패:", error);
|
||||||
toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
|
toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -251,7 +251,7 @@ export default function TableManagementPage() {
|
||||||
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
|
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 타입 정보 로드 실패:", error);
|
// console.error("컬럼 타입 정보 로드 실패:", error);
|
||||||
toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
|
toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setColumnsLoading(false);
|
setColumnsLoading(false);
|
||||||
|
|
@ -411,7 +411,7 @@ export default function TableManagementPage() {
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("저장할 컬럼 설정:", columnSetting);
|
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||||
|
|
||||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
||||||
columnSetting,
|
columnSetting,
|
||||||
|
|
@ -430,7 +430,7 @@ export default function TableManagementPage() {
|
||||||
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
|
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 설정 저장 실패:", error);
|
// console.error("컬럼 설정 저장 실패:", error);
|
||||||
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
|
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -448,7 +448,7 @@ export default function TableManagementPage() {
|
||||||
description: tableDescription,
|
description: tableDescription,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,7 +467,7 @@ export default function TableManagementPage() {
|
||||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
// console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
|
||||||
|
|
||||||
// 전체 테이블 설정을 한 번에 저장
|
// 전체 테이블 설정을 한 번에 저장
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
|
|
@ -492,7 +492,7 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("설정 저장 실패:", error);
|
// console.error("설정 저장 실패:", error);
|
||||||
toast.error("설정 저장 중 오류가 발생했습니다.");
|
toast.error("설정 저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -525,7 +525,7 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
entityColumns.forEach((col) => {
|
entityColumns.forEach((col) => {
|
||||||
if (col.referenceTable) {
|
if (col.referenceTable) {
|
||||||
console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
|
// console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
|
||||||
loadReferenceTableColumns(col.referenceTable);
|
loadReferenceTableColumns(col.referenceTable);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,15 @@
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 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 {
|
.dark {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export default function RootLayout({
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<ScreenModal />
|
<ScreenModal />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
{/* Portal 컨테이너 */}
|
||||||
|
<div id="portal-root" data-radix-portal="true" />
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
toast.error(result.error?.details || result.message);
|
toast.error(result.error?.details || result.message);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("컬럼 추가 실패:", error);
|
// console.error("컬럼 추가 실패:", error);
|
||||||
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
|
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// 상태에 따른 Badge 색상 결정
|
// 상태에 따른 Badge 색상 결정
|
||||||
console.log(companies);
|
// console.log(companies);
|
||||||
// 로딩 상태 렌더링
|
// 로딩 상태 렌더링
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
toast.error("검증 실패. 오류를 확인해주세요.");
|
toast.error("검증 실패. 오류를 확인해주세요.");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("테이블 검증 실패:", error);
|
// console.error("테이블 검증 실패:", error);
|
||||||
toast.error("검증 중 오류가 발생했습니다.");
|
toast.error("검증 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
|
|
@ -210,7 +210,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
toast.error(result.error?.details || result.message);
|
toast.error(result.error?.details || result.message);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("테이블 생성 실패:", error);
|
// console.error("테이블 생성 실패:", error);
|
||||||
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
|
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
setLogs(logsResult.logs);
|
setLogs(logsResult.logs);
|
||||||
setStatistics(statsResult);
|
setStatistics(statsResult);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DDL 로그 로드 실패:", error);
|
// console.error("DDL 로그 로드 실패:", error);
|
||||||
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) setLoading(false);
|
if (showLoading) setLoading(false);
|
||||||
|
|
@ -101,7 +101,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
||||||
loadData(false);
|
loadData(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("로그 정리 실패:", error);
|
// console.error("로그 정리 실패:", error);
|
||||||
toast.error("로그 정리에 실패했습니다.");
|
toast.error("로그 정리에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -46,21 +46,21 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
parentCompanyCode,
|
parentCompanyCode,
|
||||||
uiTexts,
|
uiTexts,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
// console.log("🎯 MenuFormModal 렌더링 - Props:", {
|
||||||
isOpen,
|
// isOpen,
|
||||||
menuId,
|
// menuId,
|
||||||
parentId,
|
// parentId,
|
||||||
menuType,
|
// menuType,
|
||||||
level,
|
// level,
|
||||||
parentCompanyCode,
|
// parentCompanyCode,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 다국어 텍스트 가져오기 함수
|
// 다국어 텍스트 가져오기 함수
|
||||||
const getText = (key: string, fallback?: string): string => {
|
const getText = (key: string, fallback?: string): string => {
|
||||||
return uiTexts[key] || fallback || key;
|
return uiTexts[key] || fallback || key;
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
// console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
|
||||||
|
|
||||||
const [formData, setFormData] = useState<MenuFormData>({
|
const [formData, setFormData] = useState<MenuFormData>({
|
||||||
parentObjId: parentId || "0",
|
parentObjId: parentId || "0",
|
||||||
|
|
@ -93,20 +93,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
try {
|
try {
|
||||||
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||||
|
|
||||||
console.log("🔍 화면 목록 로드 디버깅:", {
|
// console.log("🔍 화면 목록 로드 디버깅:", {
|
||||||
totalScreens: response.data.length,
|
// totalScreens: response.data.length,
|
||||||
firstScreen: response.data[0],
|
// firstScreen: response.data[0],
|
||||||
firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
// firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||||
firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
// firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
||||||
allScreenIds: response.data
|
// allScreenIds: response.data
|
||||||
.map((s) => ({
|
// .map((s) => ({
|
||||||
screenId: s.screenId,
|
// screenId: s.screenId,
|
||||||
legacyId: s.id,
|
// legacyId: s.id,
|
||||||
name: s.screenName,
|
// name: s.screenName,
|
||||||
code: s.screenCode,
|
// code: s.screenCode,
|
||||||
}))
|
// }))
|
||||||
.slice(0, 5), // 처음 5개만 출력
|
// .slice(0, 5), // 처음 5개만 출력
|
||||||
});
|
// });
|
||||||
|
|
||||||
setScreens(response.data);
|
setScreens(response.data);
|
||||||
console.log("✅ 화면 목록 로드 완료:", response.data.length);
|
console.log("✅ 화면 목록 로드 완료:", response.data.length);
|
||||||
|
|
@ -118,14 +118,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
// 화면 선택 시 URL 자동 설정
|
// 화면 선택 시 URL 자동 설정
|
||||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
console.log("🖥️ 화면 선택 디버깅:", {
|
// console.log("🖥️ 화면 선택 디버깅:", {
|
||||||
screen,
|
// screen,
|
||||||
screenId: screen.screenId,
|
// screenId: screen.screenId,
|
||||||
screenIdType: typeof screen.screenId,
|
// screenIdType: typeof screen.screenId,
|
||||||
legacyId: screen.id,
|
// legacyId: screen.id,
|
||||||
allFields: Object.keys(screen),
|
// allFields: Object.keys(screen),
|
||||||
screenValues: Object.values(screen),
|
// screenValues: Object.values(screen),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// ScreenDefinition에서는 screenId 필드를 사용
|
// ScreenDefinition에서는 screenId 필드를 사용
|
||||||
const actualScreenId = screen.screenId || screen.id;
|
const actualScreenId = screen.screenId || screen.id;
|
||||||
|
|
@ -154,26 +154,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
menuUrl: screenUrl,
|
menuUrl: screenUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("🖥️ 화면 선택 완료:", {
|
// console.log("🖥️ 화면 선택 완료:", {
|
||||||
screenId: screen.screenId,
|
// screenId: screen.screenId,
|
||||||
legacyId: screen.id,
|
// legacyId: screen.id,
|
||||||
actualScreenId,
|
// actualScreenId,
|
||||||
screenName: screen.screenName,
|
// screenName: screen.screenName,
|
||||||
menuType: menuType,
|
// menuType: menuType,
|
||||||
formDataMenuType: formData.menuType,
|
// formDataMenuType: formData.menuType,
|
||||||
isAdminMenu,
|
// isAdminMenu,
|
||||||
generatedUrl: screenUrl,
|
// generatedUrl: screenUrl,
|
||||||
});
|
// });
|
||||||
};
|
};
|
||||||
|
|
||||||
// URL 타입 변경 시 처리
|
// URL 타입 변경 시 처리
|
||||||
const handleUrlTypeChange = (type: "direct" | "screen") => {
|
const handleUrlTypeChange = (type: "direct" | "screen") => {
|
||||||
console.log("🔄 URL 타입 변경:", {
|
// console.log("🔄 URL 타입 변경:", {
|
||||||
from: urlType,
|
// from: urlType,
|
||||||
to: type,
|
// to: type,
|
||||||
currentSelectedScreen: selectedScreen?.screenName,
|
// currentSelectedScreen: selectedScreen?.screenName,
|
||||||
currentUrl: formData.menuUrl,
|
// currentUrl: formData.menuUrl,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setUrlType(type);
|
setUrlType(type);
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
console.log("API 호출 시작 - menuId:", menuId);
|
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);
|
const response = await menuApi.getMenuInfo(menuId);
|
||||||
console.log("메뉴 정보 조회 응답:", response);
|
console.log("메뉴 정보 조회 응답:", response);
|
||||||
|
|
@ -285,29 +285,29 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
|
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
|
||||||
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||||
if (screenId) {
|
if (screenId) {
|
||||||
console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
// console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
||||||
menuUrl,
|
// menuUrl,
|
||||||
screenId,
|
// screenId,
|
||||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
// hasAdminParam: menuUrl.includes("mode=admin"),
|
||||||
currentScreensCount: screens.length,
|
// currentScreensCount: screens.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 화면 설정 함수
|
// 화면 설정 함수
|
||||||
const setScreenFromId = () => {
|
const setScreenFromId = () => {
|
||||||
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||||
if (screen) {
|
if (screen) {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
// console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
||||||
screen,
|
// screen,
|
||||||
originalUrl: menuUrl,
|
// originalUrl: menuUrl,
|
||||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
// hasAdminParam: menuUrl.includes("mode=admin"),
|
||||||
});
|
// });
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
// console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
||||||
screenId,
|
// screenId,
|
||||||
availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
// availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
||||||
});
|
// });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -330,26 +330,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("설정된 폼 데이터:", {
|
// console.log("설정된 폼 데이터:", {
|
||||||
objid: menu.objid || menu.OBJID,
|
// objid: menu.objid || menu.OBJID,
|
||||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
// parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||||
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
// menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
||||||
menuUrl: menu.menu_url || menu.MENU_URL || "",
|
// menuUrl: menu.menu_url || menu.MENU_URL || "",
|
||||||
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
// menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
||||||
seq: menu.seq || menu.SEQ || 1,
|
// seq: menu.seq || menu.SEQ || 1,
|
||||||
menuType: convertedMenuType,
|
// menuType: convertedMenuType,
|
||||||
status: convertedStatus,
|
// status: convertedStatus,
|
||||||
companyCode: companyCode,
|
// companyCode: companyCode,
|
||||||
langKey: langKey,
|
// langKey: langKey,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("메뉴 정보 로딩 오류:", error);
|
console.error("메뉴 정보 로딩 오류:", error);
|
||||||
console.error("오류 상세 정보:", {
|
// console.error("오류 상세 정보:", {
|
||||||
message: error?.message,
|
// message: error?.message,
|
||||||
stack: error?.stack,
|
// stack: error?.stack,
|
||||||
response: error?.response,
|
// response: error?.response,
|
||||||
});
|
// });
|
||||||
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
|
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -390,13 +390,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
langKey: "", // 다국어 키 초기화
|
langKey: "", // 다국어 키 초기화
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("메뉴 등록 기본값 설정:", {
|
// console.log("메뉴 등록 기본값 설정:", {
|
||||||
parentObjId: parentId || "0",
|
// parentObjId: parentId || "0",
|
||||||
menuType: defaultMenuType,
|
// menuType: defaultMenuType,
|
||||||
status: "ACTIVE",
|
// status: "ACTIVE",
|
||||||
companyCode: "",
|
// companyCode: "",
|
||||||
langKey: "",
|
// langKey: "",
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
}, [menuId, parentId, menuType]);
|
}, [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);
|
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||||
if (screen) {
|
if (screen) {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
// console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
||||||
screenId,
|
// screenId,
|
||||||
screenName: screen.screenName,
|
// screenName: screen.screenName,
|
||||||
menuUrl,
|
// menuUrl,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
defaultTexts[key] = defaultText;
|
defaultTexts[key] = defaultText;
|
||||||
});
|
});
|
||||||
setUiTexts(defaultTexts);
|
setUiTexts(defaultTexts);
|
||||||
console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
|
// console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 기본 텍스트 반환 함수
|
// 기본 텍스트 반환 함수
|
||||||
|
|
@ -303,20 +303,20 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// uiTexts 상태 변경 감지
|
// uiTexts 상태 변경 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔄 uiTexts 상태 변경됨:", {
|
// console.log("🔄 uiTexts 상태 변경됨:", {
|
||||||
count: Object.keys(uiTexts).length,
|
// count: Object.keys(uiTexts).length,
|
||||||
sampleKeys: Object.keys(uiTexts).slice(0, 5),
|
// sampleKeys: Object.keys(uiTexts).slice(0, 5),
|
||||||
sampleValues: Object.entries(uiTexts)
|
// sampleValues: Object.entries(uiTexts)
|
||||||
.slice(0, 3)
|
// .slice(0, 3)
|
||||||
.map(([k, v]) => `${k}: ${v}`),
|
// .map(([k, v]) => `${k}: ${v}`),
|
||||||
});
|
// });
|
||||||
}, [uiTexts]);
|
}, [uiTexts]);
|
||||||
|
|
||||||
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (userLang && !uiTextsLoading) {
|
if (userLang && !uiTextsLoading) {
|
||||||
console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
|
// console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
|
||||||
loadUITexts();
|
loadUITexts();
|
||||||
}
|
}
|
||||||
}, 300); // 300ms 후 실행
|
}, 300); // 300ms 후 실행
|
||||||
|
|
@ -328,7 +328,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fallbackTimer = setTimeout(() => {
|
const fallbackTimer = setTimeout(() => {
|
||||||
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
|
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
|
||||||
console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
|
// console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
|
||||||
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
|
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
|
||||||
if (!userLang) {
|
if (!userLang) {
|
||||||
initializeDefaultTexts();
|
initializeDefaultTexts();
|
||||||
|
|
@ -378,15 +378,15 @@ export const MenuManagement: React.FC = () => {
|
||||||
}, [isCompanyDropdownOpen]);
|
}, [isCompanyDropdownOpen]);
|
||||||
|
|
||||||
const loadMenus = async (showLoading = true) => {
|
const loadMenus = async (showLoading = true) => {
|
||||||
console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
|
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
|
||||||
try {
|
try {
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
await refreshMenus();
|
await refreshMenus();
|
||||||
console.log("📋 메뉴 목록 조회 성공");
|
// console.log("📋 메뉴 목록 조회 성공");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 메뉴 목록 조회 실패:", error);
|
// console.error("❌ 메뉴 목록 조회 실패:", error);
|
||||||
toast.error(getUITextSync("message.error.load.menu.list"));
|
toast.error(getUITextSync("message.error.load.menu.list"));
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
|
|
@ -397,21 +397,21 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
console.log("🏢 회사 목록 조회 시작");
|
// console.log("🏢 회사 목록 조회 시작");
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/admin/companies");
|
const response = await apiClient.get("/admin/companies");
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
console.log("🏢 회사 목록 응답:", response.data);
|
// console.log("🏢 회사 목록 응답:", response.data);
|
||||||
const companyList = response.data.data.map((company: any) => ({
|
const companyList = response.data.data.map((company: any) => ({
|
||||||
code: company.company_code || company.companyCode,
|
code: company.company_code || company.companyCode,
|
||||||
name: company.company_name || company.companyName,
|
name: company.company_name || company.companyName,
|
||||||
}));
|
}));
|
||||||
console.log("🏢 변환된 회사 목록:", companyList);
|
// console.log("🏢 변환된 회사 목록:", companyList);
|
||||||
setCompanies(companyList);
|
setCompanies(companyList);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 회사 목록 조회 실패:", error);
|
// console.error("❌ 회사 목록 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -421,7 +421,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// userLang이 설정되지 않았으면 기본값 설정
|
// userLang이 설정되지 않았으면 기본값 설정
|
||||||
if (!userLang) {
|
if (!userLang) {
|
||||||
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
|
// console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
|
||||||
const defaultTexts: Record<string, string> = {};
|
const defaultTexts: Record<string, string> = {};
|
||||||
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||||
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
|
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
|
||||||
|
|
@ -432,7 +432,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
|
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
|
||||||
if (Object.keys(uiTexts).length === 0) {
|
if (Object.keys(uiTexts).length === 0) {
|
||||||
console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
|
// console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
|
||||||
const defaultTexts: Record<string, string> = {};
|
const defaultTexts: Record<string, string> = {};
|
||||||
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
|
||||||
defaultTexts[key] = getDefaultText(key);
|
defaultTexts[key] = getDefaultText(key);
|
||||||
|
|
@ -440,14 +440,14 @@ export const MenuManagement: React.FC = () => {
|
||||||
setUiTexts(defaultTexts);
|
setUiTexts(defaultTexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🌐 UI 다국어 텍스트 로드 시작", {
|
// console.log("🌐 UI 다국어 텍스트 로드 시작", {
|
||||||
userLang,
|
// userLang,
|
||||||
apiParams: {
|
// apiParams: {
|
||||||
companyCode: "*",
|
// companyCode: "*",
|
||||||
menuCode: "menu.management",
|
// menuCode: "menu.management",
|
||||||
userLang: userLang,
|
// userLang: userLang,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
setUiTextsLoading(true);
|
setUiTextsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -467,28 +467,28 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const translations = response.data.data;
|
const translations = response.data.data;
|
||||||
console.log("🌐 배치 다국어 텍스트 응답:", translations);
|
// console.log("🌐 배치 다국어 텍스트 응답:", translations);
|
||||||
|
|
||||||
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
|
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
|
||||||
const mergedTranslations = { ...uiTexts, ...translations };
|
const mergedTranslations = { ...uiTexts, ...translations };
|
||||||
console.log("🔧 setUiTexts 호출 전:", {
|
// console.log("🔧 setUiTexts 호출 전:", {
|
||||||
translationsCount: Object.keys(translations).length,
|
// translationsCount: Object.keys(translations).length,
|
||||||
mergedCount: Object.keys(mergedTranslations).length,
|
// mergedCount: Object.keys(mergedTranslations).length,
|
||||||
});
|
// });
|
||||||
setUiTexts(mergedTranslations);
|
setUiTexts(mergedTranslations);
|
||||||
console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
|
// console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
|
||||||
|
|
||||||
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
|
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
|
||||||
setTranslationCache(userLang, mergedTranslations);
|
setTranslationCache(userLang, mergedTranslations);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
|
// console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
|
||||||
// API 실패 시에도 기존 uiTexts는 유지
|
// API 실패 시에도 기존 uiTexts는 유지
|
||||||
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
// console.error("❌ UI 다국어 텍스트 로드 실패:", error);
|
||||||
// API 실패 시에도 기존 uiTexts는 유지
|
// API 실패 시에도 기존 uiTexts는 유지
|
||||||
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
|
||||||
} finally {
|
} finally {
|
||||||
setUiTextsLoading(false);
|
setUiTextsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -519,12 +519,12 @@ export const MenuManagement: React.FC = () => {
|
||||||
|
|
||||||
// 다국어 API 테스트 함수 (getUITextSync 사용)
|
// 다국어 API 테스트 함수 (getUITextSync 사용)
|
||||||
const testMultiLangAPI = async () => {
|
const testMultiLangAPI = async () => {
|
||||||
console.log("🧪 다국어 API 테스트 시작");
|
// console.log("🧪 다국어 API 테스트 시작");
|
||||||
try {
|
try {
|
||||||
const text = getUITextSync("menu.management.admin");
|
const text = getUITextSync("menu.management.admin");
|
||||||
console.log("🧪 다국어 API 테스트 결과:", text);
|
// console.log("🧪 다국어 API 테스트 결과:", text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 다국어 API 테스트 실패:", error);
|
// console.error("❌ 다국어 API 테스트 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -576,14 +576,14 @@ export const MenuManagement: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditMenu = (menuId: string) => {
|
const handleEditMenu = (menuId: string) => {
|
||||||
console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
|
// console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
|
||||||
|
|
||||||
// 현재 메뉴 정보 찾기
|
// 현재 메뉴 정보 찾기
|
||||||
const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus;
|
const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus;
|
||||||
const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
|
const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
|
||||||
|
|
||||||
if (menuToEdit) {
|
if (menuToEdit) {
|
||||||
console.log("수정할 메뉴 정보:", menuToEdit);
|
// console.log("수정할 메뉴 정보:", menuToEdit);
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
menuId: menuId,
|
menuId: menuId,
|
||||||
|
|
@ -593,15 +593,15 @@ export const MenuManagement: React.FC = () => {
|
||||||
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("설정된 formData:", {
|
// console.log("설정된 formData:", {
|
||||||
menuId: menuId,
|
// menuId: menuId,
|
||||||
parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
|
// parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
|
||||||
menuType: selectedMenuType,
|
// menuType: selectedMenuType,
|
||||||
level: 0,
|
// level: 0,
|
||||||
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
// parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
console.error("수정할 메뉴를 찾을 수 없음:", menuId);
|
// console.error("수정할 메뉴를 찾을 수 없음:", menuId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormModalOpen(true);
|
setFormModalOpen(true);
|
||||||
|
|
@ -640,31 +640,31 @@ export const MenuManagement: React.FC = () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const menuIds = Array.from(selectedMenus);
|
const menuIds = Array.from(selectedMenus);
|
||||||
console.log("삭제할 메뉴 IDs:", menuIds);
|
// console.log("삭제할 메뉴 IDs:", menuIds);
|
||||||
|
|
||||||
toast.info(getUITextSync("message.menu.delete.processing"));
|
toast.info(getUITextSync("message.menu.delete.processing"));
|
||||||
|
|
||||||
const response = await menuApi.deleteMenusBatch(menuIds);
|
const response = await menuApi.deleteMenusBatch(menuIds);
|
||||||
console.log("삭제 API 응답:", response);
|
// console.log("삭제 API 응답:", response);
|
||||||
console.log("응답 구조:", {
|
// console.log("응답 구조:", {
|
||||||
success: response.success,
|
// success: response.success,
|
||||||
data: response.data,
|
// data: response.data,
|
||||||
message: response.message,
|
// message: response.message,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const { deletedCount, failedCount } = response.data;
|
const { deletedCount, failedCount } = response.data;
|
||||||
console.log("삭제 결과:", { deletedCount, failedCount });
|
// console.log("삭제 결과:", { deletedCount, failedCount });
|
||||||
|
|
||||||
// 선택된 메뉴 초기화
|
// 선택된 메뉴 초기화
|
||||||
setSelectedMenus(new Set());
|
setSelectedMenus(new Set());
|
||||||
|
|
||||||
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
|
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
|
||||||
console.log("메뉴 목록 새로고침 시작");
|
// console.log("메뉴 목록 새로고침 시작");
|
||||||
await loadMenus(false);
|
await loadMenus(false);
|
||||||
// 전역 메뉴 상태도 업데이트
|
// 전역 메뉴 상태도 업데이트
|
||||||
await refreshMenus();
|
await refreshMenus();
|
||||||
console.log("메뉴 목록 새로고침 완료");
|
// console.log("메뉴 목록 새로고침 완료");
|
||||||
|
|
||||||
// 삭제 결과 메시지
|
// 삭제 결과 메시지
|
||||||
if (failedCount === 0) {
|
if (failedCount === 0) {
|
||||||
|
|
@ -678,11 +678,11 @@ export const MenuManagement: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error("삭제 실패:", response);
|
// console.error("삭제 실패:", response);
|
||||||
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
|
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 삭제 중 오류:", error);
|
// console.error("메뉴 삭제 중 오류:", error);
|
||||||
toast.error(getUITextSync("message.menu.delete.failed"));
|
toast.error(getUITextSync("message.menu.delete.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
|
@ -718,7 +718,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
toast.error(response.message);
|
toast.error(response.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 상태 토글 오류:", error);
|
// console.error("메뉴 상태 토글 오류:", error);
|
||||||
toast.error(getUITextSync("message.menu.status.toggle.failed"));
|
toast.error(getUITextSync("message.menu.status.toggle.failed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -785,15 +785,15 @@ export const MenuManagement: React.FC = () => {
|
||||||
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
|
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
|
||||||
|
|
||||||
// 디버깅을 위한 간단한 상태 표시
|
// 디버깅을 위한 간단한 상태 표시
|
||||||
console.log("🔍 MenuManagement 렌더링 상태:", {
|
// console.log("🔍 MenuManagement 렌더링 상태:", {
|
||||||
loading,
|
// loading,
|
||||||
uiTextsLoading,
|
// uiTextsLoading,
|
||||||
uiTextsCount,
|
// uiTextsCount,
|
||||||
adminMenusCount,
|
// adminMenusCount,
|
||||||
userMenusCount,
|
// userMenusCount,
|
||||||
selectedMenuType,
|
// selectedMenuType,
|
||||||
userLang,
|
// userLang,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,9 @@ export default function MultiLangPage() {
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
const fetchCompanies = async () => {
|
const fetchCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("회사 목록 조회 시작");
|
// console.log("회사 목록 조회 시작");
|
||||||
const response = await apiClient.get("/admin/companies");
|
const response = await apiClient.get("/admin/companies");
|
||||||
console.log("회사 목록 응답 데이터:", response.data);
|
// console.log("회사 목록 응답 데이터:", response.data);
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|
@ -74,13 +74,13 @@ export default function MultiLangPage() {
|
||||||
code: company.company_code,
|
code: company.company_code,
|
||||||
name: company.company_name,
|
name: company.company_name,
|
||||||
}));
|
}));
|
||||||
console.log("변환된 회사 목록:", companyList);
|
// console.log("변환된 회사 목록:", companyList);
|
||||||
setCompanies(companyList);
|
setCompanies(companyList);
|
||||||
} else {
|
} else {
|
||||||
console.error("회사 목록 조회 실패:", data.message);
|
// console.error("회사 목록 조회 실패:", data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("회사 목록 조회 실패:", error);
|
// console.error("회사 목록 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ export default function MultiLangPage() {
|
||||||
setLanguages(data.data);
|
setLanguages(data.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 목록 조회 실패:", error);
|
// console.error("언어 목록 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -103,13 +103,13 @@ export default function MultiLangPage() {
|
||||||
const response = await apiClient.get("/multilang/keys");
|
const response = await apiClient.get("/multilang/keys");
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
||||||
setLangKeys(data.data);
|
setLangKeys(data.data);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 키 목록 로드 실패:", data.message);
|
// console.error("❌ 키 목록 로드 실패:", data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("다국어 키 목록 조회 실패:", error);
|
// console.error("다국어 키 목록 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -146,25 +146,25 @@ export default function MultiLangPage() {
|
||||||
// 선택된 키의 다국어 텍스트 조회
|
// 선택된 키의 다국어 텍스트 조회
|
||||||
const fetchLangTexts = async (keyId: number) => {
|
const fetchLangTexts = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
||||||
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
console.log("다국어 텍스트 조회 응답:", data);
|
// console.log("다국어 텍스트 조회 응답:", data);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setLangTexts(data.data);
|
setLangTexts(data.data);
|
||||||
// 편집용 텍스트 초기화
|
// 편집용 텍스트 초기화
|
||||||
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
||||||
setEditingTexts(editingData);
|
setEditingTexts(editingData);
|
||||||
console.log("편집용 텍스트 설정:", editingData);
|
// console.log("편집용 텍스트 설정:", editingData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("다국어 텍스트 조회 실패:", error);
|
// console.error("다국어 텍스트 조회 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 언어 키 선택 처리
|
// 언어 키 선택 처리
|
||||||
const handleKeySelect = (key: LangKey) => {
|
const handleKeySelect = (key: LangKey) => {
|
||||||
console.log("언어 키 선택:", key);
|
// console.log("언어 키 선택:", key);
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
fetchLangTexts(key.keyId);
|
fetchLangTexts(key.keyId);
|
||||||
};
|
};
|
||||||
|
|
@ -172,9 +172,9 @@ export default function MultiLangPage() {
|
||||||
// 디버깅용 useEffect
|
// 디버깅용 useEffect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedKey) {
|
if (selectedKey) {
|
||||||
console.log("선택된 키 변경:", selectedKey);
|
// console.log("선택된 키 변경:", selectedKey);
|
||||||
console.log("언어 목록:", languages);
|
// console.log("언어 목록:", languages);
|
||||||
console.log("편집 텍스트:", editingTexts);
|
// console.log("편집 텍스트:", editingTexts);
|
||||||
}
|
}
|
||||||
}, [selectedKey, languages, editingTexts]);
|
}, [selectedKey, languages, editingTexts]);
|
||||||
|
|
||||||
|
|
@ -222,7 +222,7 @@ export default function MultiLangPage() {
|
||||||
fetchLangTexts(selectedKey.keyId);
|
fetchLangTexts(selectedKey.keyId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("텍스트 저장 실패:", error);
|
// console.error("텍스트 저장 실패:", error);
|
||||||
alert("저장에 실패했습니다.");
|
alert("저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -271,7 +271,7 @@ export default function MultiLangPage() {
|
||||||
alert(`오류: ${result.message}`);
|
alert(`오류: ${result.message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 저장 중 오류:", error);
|
// console.error("언어 저장 중 오류:", error);
|
||||||
alert("언어 저장 중 오류가 발생했습니다.");
|
alert("언어 저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -307,7 +307,7 @@ export default function MultiLangPage() {
|
||||||
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 삭제 중 오류:", error);
|
// console.error("언어 삭제 중 오류:", error);
|
||||||
alert("언어 삭제 중 오류가 발생했습니다.");
|
alert("언어 삭제 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -369,7 +369,7 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 키 저장 실패:", error);
|
// console.error("언어 키 저장 실패:", error);
|
||||||
alert("언어 키 저장에 실패했습니다.");
|
alert("언어 키 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -397,7 +397,7 @@ export default function MultiLangPage() {
|
||||||
alert("상태 변경 중 오류가 발생했습니다.");
|
alert("상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("키 상태 토글 실패:", error);
|
// console.error("키 상태 토글 실패:", error);
|
||||||
alert("키 상태 변경 중 오류가 발생했습니다.");
|
alert("키 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -414,7 +414,7 @@ export default function MultiLangPage() {
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 상태 토글 실패:", error);
|
// console.error("언어 상태 토글 실패:", error);
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -463,7 +463,7 @@ export default function MultiLangPage() {
|
||||||
alert("일부 키 삭제에 실패했습니다.");
|
alert("일부 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("선택된 키 삭제 실패:", error);
|
// console.error("선택된 키 삭제 실패:", error);
|
||||||
alert("선택된 키 삭제에 실패했습니다.");
|
alert("선택된 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -485,7 +485,7 @@ export default function MultiLangPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("언어 키 삭제 실패:", error);
|
// console.error("언어 키 삭제 실패:", error);
|
||||||
alert("언어 키 삭제에 실패했습니다.");
|
alert("언어 키 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,15 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 디버그: 전달받은 메뉴 데이터 확인
|
// 디버그: 전달받은 메뉴 데이터 확인
|
||||||
console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
|
// console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
|
||||||
total: menus.length,
|
// total: menus.length,
|
||||||
sample: menus.slice(0, 3),
|
// sample: menus.slice(0, 3),
|
||||||
keys: menus.length > 0 ? Object.keys(menus[0]) : [],
|
// keys: menus.length > 0 ? Object.keys(menus[0]) : [],
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 메뉴 선택
|
// 메뉴 선택
|
||||||
const handleMenuSelect = async (menuId: string) => {
|
const handleMenuSelect = async (menuId: string) => {
|
||||||
console.log("메뉴 선택:", menuId);
|
// console.log("메뉴 선택:", menuId);
|
||||||
setSelectedMenuId(menuId);
|
setSelectedMenuId(menuId);
|
||||||
|
|
||||||
// 다양한 형식의 objid 대응
|
// 다양한 형식의 objid 대응
|
||||||
|
|
@ -57,7 +57,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
return objid?.toString() === menuId;
|
return objid?.toString() === menuId;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("선택된 메뉴:", menu);
|
// console.log("선택된 메뉴:", menu);
|
||||||
setSelectedMenu(menu || null);
|
setSelectedMenu(menu || null);
|
||||||
|
|
||||||
if (menu) {
|
if (menu) {
|
||||||
|
|
@ -75,7 +75,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
setAssignedScreens(screens);
|
setAssignedScreens(screens);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("할당된 화면 로드 실패:", error);
|
// console.error("할당된 화면 로드 실패:", error);
|
||||||
toast.error("할당된 화면 목록을 불러오는데 실패했습니다.");
|
toast.error("할당된 화면 목록을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -94,7 +94,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
|
|
||||||
setAvailableScreens(available);
|
setAvailableScreens(available);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("사용 가능한 화면 로드 실패:", error);
|
// console.error("사용 가능한 화면 로드 실패:", error);
|
||||||
toast.error("사용 가능한 화면 목록을 불러오는데 실패했습니다.");
|
toast.error("사용 가능한 화면 목록을 불러오는데 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -118,7 +118,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
setShowAssignDialog(false);
|
setShowAssignDialog(false);
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 할당 실패:", error);
|
// console.error("화면 할당 실패:", error);
|
||||||
toast.error("화면 할당에 실패했습니다.");
|
toast.error("화면 할당에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -142,7 +142,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
setShowUnassignDialog(false);
|
setShowUnassignDialog(false);
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 할당 해제 실패:", error);
|
// console.error("화면 할당 해제 실패:", error);
|
||||||
toast.error("화면 할당 해제에 실패했습니다.");
|
toast.error("화면 할당 해제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -162,14 +162,14 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
|
|
||||||
// 단순화된 메뉴 옵션 생성 (모든 메뉴를 평면적으로 표시)
|
// 단순화된 메뉴 옵션 생성 (모든 메뉴를 평면적으로 표시)
|
||||||
const getMenuOptions = (menuList: MenuItem[]): JSX.Element[] => {
|
const getMenuOptions = (menuList: MenuItem[]): JSX.Element[] => {
|
||||||
console.log("메뉴 옵션 생성:", {
|
// console.log("메뉴 옵션 생성:", {
|
||||||
total: menuList.length,
|
// total: menuList.length,
|
||||||
sample: menuList.slice(0, 3).map((m) => ({
|
// sample: menuList.slice(0, 3).map((m) => ({
|
||||||
objid: m.objid || m.OBJID || (m as any).objid,
|
// objid: m.objid || m.OBJID || (m as any).objid,
|
||||||
name: m.menu_name_kor || m.MENU_NAME_KOR || (m as any).menu_name_kor,
|
// 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,
|
// parent: m.parent_obj_id || m.PARENT_OBJ_ID || (m as any).parent_obj_id,
|
||||||
})),
|
// })),
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!menuList || menuList.length === 0) {
|
if (!menuList || menuList.length === 0) {
|
||||||
return [
|
return [
|
||||||
|
|
@ -188,7 +188,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
// 들여쓰기 (레벨에 따라)
|
// 들여쓰기 (레벨에 따라)
|
||||||
const indent = " ".repeat(Math.max(0, lev));
|
const indent = " ".repeat(Math.max(0, lev));
|
||||||
|
|
||||||
console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
|
// console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectItem key={menuObjid?.toString() || `menu-${index}`} value={menuObjid?.toString() || `menu-${index}`}>
|
<SelectItem key={menuObjid?.toString() || `menu-${index}`} value={menuObjid?.toString() || `menu-${index}`}>
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,32 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
return;
|
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 });
|
console.log("쿼리 실행 시작:", { connectionId, query });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -116,14 +116,6 @@ export function UserToolbar({
|
||||||
|
|
||||||
{/* 고급 검색 필드들 */}
|
{/* 고급 검색 필드들 */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<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>
|
<div>
|
||||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
);
|
||||||
|
|
@ -1,46 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { ConnectionTypeSelector } from "./LeftPanel/ConnectionTypeSelector";
|
||||||
import { Button } from "@/components/ui/button";
|
import { MappingInfoPanel } from "./LeftPanel/MappingInfoPanel";
|
||||||
import { toast } from "sonner";
|
import { StepProgress } from "./RightPanel/StepProgress";
|
||||||
import { X, ArrowLeft } from "lucide-react";
|
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
|
const initialState: DataConnectionState = {
|
||||||
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>(() => ({
|
|
||||||
connectionType: "data_save",
|
connectionType: "data_save",
|
||||||
currentStep: 1,
|
currentStep: 1,
|
||||||
fieldMappings: [],
|
fieldMappings: [],
|
||||||
|
|
@ -52,762 +23,87 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
estimatedRows: 0,
|
estimatedRows: 0,
|
||||||
actionType: "INSERT",
|
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,
|
isLoading: false,
|
||||||
validationErrors: [],
|
validationErrors: [],
|
||||||
|
};
|
||||||
|
|
||||||
// 컬럼 정보 초기화
|
export const DataConnectionDesigner: React.FC = () => {
|
||||||
fromColumns: [],
|
const [state, setState] = useState<DataConnectionState>(initialState);
|
||||||
toColumns: [],
|
const { isMobile, isTablet } = useResponsive();
|
||||||
...initialData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드)
|
|
||||||
const diagramId = initialData?.diagramId;
|
|
||||||
|
|
||||||
// 🔄 초기 데이터 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData && Object.keys(initialData).length > 1) {
|
|
||||||
console.log("🔄 초기 데이터 로드:", initialData);
|
|
||||||
|
|
||||||
// 로드된 데이터로 state 업데이트
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
connectionType: initialData.connectionType || prev.connectionType,
|
|
||||||
|
|
||||||
// 🔧 관계 정보 로드
|
|
||||||
relationshipName: initialData.relationshipName || prev.relationshipName,
|
|
||||||
description: initialData.description || prev.description,
|
|
||||||
groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator,
|
|
||||||
|
|
||||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
|
||||||
toConnection: initialData.toConnection || prev.toConnection,
|
|
||||||
fromTable: initialData.fromTable || prev.fromTable,
|
|
||||||
toTable: initialData.toTable || prev.toTable,
|
|
||||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
|
||||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
|
||||||
|
|
||||||
// 🔧 외부호출 설정 로드
|
|
||||||
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
|
|
||||||
|
|
||||||
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
|
||||||
actionGroups:
|
|
||||||
initialData.actionGroups ||
|
|
||||||
// 기존 단일 액션 데이터를 그룹으로 변환
|
|
||||||
(initialData.actionType || initialData.actionConditions
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "group_1",
|
|
||||||
name: "기본 액션 그룹",
|
|
||||||
logicalOperator: "AND" as const,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: "action_1",
|
|
||||||
name: "액션 1",
|
|
||||||
actionType: initialData.actionType || ("insert" as const),
|
|
||||||
conditions: initialData.actionConditions || [],
|
|
||||||
fieldMappings: initialData.actionFieldMappings || [],
|
|
||||||
isEnabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isEnabled: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: prev.actionGroups),
|
|
||||||
|
|
||||||
// 기존 호환성 필드들
|
|
||||||
actionType: initialData.actionType || prev.actionType,
|
|
||||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
|
||||||
actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings,
|
|
||||||
|
|
||||||
currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log("✅ 초기 데이터 로드 완료");
|
|
||||||
}
|
|
||||||
}, [initialData]);
|
|
||||||
|
|
||||||
// 🎯 액션 핸들러들
|
|
||||||
const actions: DataConnectionActions = {
|
|
||||||
// 연결 타입 설정
|
|
||||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
|
||||||
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
connectionType: type,
|
|
||||||
// 타입 변경 시 상태 초기화
|
|
||||||
currentStep: 1,
|
|
||||||
fromConnection: undefined,
|
|
||||||
toConnection: undefined,
|
|
||||||
fromTable: undefined,
|
|
||||||
toTable: undefined,
|
|
||||||
fieldMappings: [],
|
|
||||||
validationErrors: [],
|
|
||||||
}));
|
|
||||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 🔧 관계 정보 설정
|
|
||||||
setRelationshipName: useCallback((name: string) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
relationshipName: name,
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
setDescription: useCallback((description: string) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
description: description,
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => {
|
|
||||||
setState((prev) => ({ ...prev, groupsLogicalOperator: operator }));
|
|
||||||
console.log("🔄 그룹 간 논리 연산자 변경:", operator);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 단계 이동
|
|
||||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
|
||||||
setState((prev) => ({ ...prev, currentStep: step }));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 연결 선택
|
|
||||||
selectConnection: useCallback((type: "from" | "to", connection: Connection) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type === "from" ? "fromConnection" : "toConnection"]: connection,
|
|
||||||
// 연결 변경 시 테이블과 매핑 초기화
|
|
||||||
[type === "from" ? "fromTable" : "toTable"]: undefined,
|
|
||||||
fieldMappings: [],
|
|
||||||
}));
|
|
||||||
toast.success(`${type === "from" ? "소스" : "대상"} 연결이 선택되었습니다: ${connection.name}`);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 테이블 선택
|
|
||||||
selectTable: useCallback((type: "from" | "to", table: TableInfo) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
|
||||||
// 테이블 변경 시 매핑과 컬럼 정보 초기화
|
|
||||||
fieldMappings: [],
|
|
||||||
fromColumns: type === "from" ? [] : prev.fromColumns,
|
|
||||||
toColumns: type === "to" ? [] : prev.toColumns,
|
|
||||||
}));
|
|
||||||
toast.success(
|
|
||||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
|
||||||
);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 컬럼 정보 로드 (중앙 관리)
|
|
||||||
loadColumns: useCallback(async () => {
|
|
||||||
if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) {
|
|
||||||
console.log("❌ 컬럼 로드: 필수 정보 누락");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 로드된 경우 스킵 (배열 길이로 확인)
|
|
||||||
if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) {
|
|
||||||
console.log("✅ 컬럼 정보 이미 로드됨, 스킵", {
|
|
||||||
fromColumns: state.fromColumns.length,
|
|
||||||
toColumns: state.toColumns.length,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔄 중앙 컬럼 로드 시작:", {
|
|
||||||
from: `${state.fromConnection.id}/${state.fromTable.tableName}`,
|
|
||||||
to: `${state.toConnection.id}/${state.toTable.tableName}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: true,
|
|
||||||
fromColumns: [],
|
|
||||||
toColumns: [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [fromCols, toCols] = await Promise.all([
|
|
||||||
getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName),
|
|
||||||
getColumnsFromConnection(state.toConnection.id, state.toTable.tableName),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log("✅ 중앙 컬럼 로드 완료:", {
|
|
||||||
fromColumns: fromCols.length,
|
|
||||||
toColumns: toCols.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fromColumns: Array.isArray(fromCols) ? fromCols : [],
|
|
||||||
toColumns: Array.isArray(toCols) ? toCols : [],
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 중앙 컬럼 로드 실패:", error);
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
}, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]),
|
|
||||||
|
|
||||||
// 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
|
||||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
|
||||||
const newMapping: FieldMapping = {
|
|
||||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
|
||||||
fromField,
|
|
||||||
toField,
|
|
||||||
isValid: true,
|
|
||||||
validationMessage: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log("🔗 전역 매핑 생성 (호환성):", {
|
|
||||||
newMapping,
|
|
||||||
fieldName: `${fromField.columnName} → ${toField.columnName}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 필드 매핑 업데이트
|
|
||||||
updateMapping: useCallback((mappingId: string, updates: Partial<FieldMapping>) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldMappings: prev.fieldMappings.map((mapping) =>
|
|
||||||
mapping.id === mappingId ? { ...mapping, ...updates } : mapping,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
|
||||||
deleteMapping: useCallback((mappingId: string) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId });
|
|
||||||
toast.success("매핑이 삭제되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 매핑 검증
|
|
||||||
validateMappings: useCallback(async (): Promise<ValidationResult> => {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: 실제 검증 로직 구현
|
|
||||||
const result: ValidationResult = {
|
|
||||||
isValid: true,
|
|
||||||
errors: [],
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
validationErrors: result.errors,
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 제어 조건 관리 (전체 실행 조건)
|
|
||||||
addControlCondition: useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
controlConditions: [
|
|
||||||
...prev.controlConditions,
|
|
||||||
{
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: "condition",
|
|
||||||
field: "",
|
|
||||||
operator: "=",
|
|
||||||
value: "",
|
|
||||||
dataType: "string",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
updateControlCondition: useCallback((index: number, condition: any) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
controlConditions: prev.controlConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
deleteControlCondition: useCallback((index: number) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
controlConditions: prev.controlConditions.filter((_, i) => i !== index),
|
|
||||||
}));
|
|
||||||
toast.success("제어 조건이 삭제되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 외부호출 설정 업데이트
|
|
||||||
updateExternalCallConfig: useCallback((config: any) => {
|
|
||||||
console.log("🔄 외부호출 설정 업데이트:", config);
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
externalCallConfig: config,
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 액션 설정 관리
|
|
||||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionType: type,
|
|
||||||
// INSERT가 아닌 경우 조건 초기화
|
|
||||||
actionConditions: type === "insert" ? [] : prev.actionConditions,
|
|
||||||
}));
|
|
||||||
toast.success(`액션 타입이 ${type.toUpperCase()}로 변경되었습니다.`);
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
addActionCondition: useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionConditions: [
|
|
||||||
...prev.actionConditions,
|
|
||||||
{
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: "condition",
|
|
||||||
field: "",
|
|
||||||
operator: "=",
|
|
||||||
value: "",
|
|
||||||
dataType: "string",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
updateActionCondition: useCallback((index: number, condition: any) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionConditions: prev.actionConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 🔧 액션 조건 배열 전체 업데이트 (ActionConditionBuilder용)
|
|
||||||
setActionConditions: useCallback((conditions: any[]) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionConditions: conditions,
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
deleteActionCondition: useCallback((index: number) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionConditions: prev.actionConditions.filter((_, i) => i !== index),
|
|
||||||
}));
|
|
||||||
toast.success("조건이 삭제되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 🎯 액션 그룹 관리 (멀티 액션)
|
|
||||||
addActionGroup: useCallback(() => {
|
|
||||||
const newGroupId = `group_${Date.now()}`;
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: [
|
|
||||||
...prev.actionGroups,
|
|
||||||
{
|
|
||||||
id: newGroupId,
|
|
||||||
name: `액션 그룹 ${prev.actionGroups.length + 1}`,
|
|
||||||
logicalOperator: "AND" as const,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: `action_${Date.now()}`,
|
|
||||||
name: "액션 1",
|
|
||||||
actionType: "insert" as const,
|
|
||||||
conditions: [],
|
|
||||||
fieldMappings: [],
|
|
||||||
isEnabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isEnabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
toast.success("새 액션 그룹이 추가되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
updateActionGroup: useCallback((groupId: string, updates: Partial<ActionGroup>) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: prev.actionGroups.map((group) => (group.id === groupId ? { ...group, ...updates } : group)),
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
deleteActionGroup: useCallback((groupId: string) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: prev.actionGroups.filter((group) => group.id !== groupId),
|
|
||||||
}));
|
|
||||||
toast.success("액션 그룹이 삭제되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
addActionToGroup: useCallback((groupId: string) => {
|
|
||||||
const newActionId = `action_${Date.now()}`;
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: prev.actionGroups.map((group) =>
|
|
||||||
group.id === groupId
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
actions: [
|
|
||||||
...group.actions,
|
|
||||||
{
|
|
||||||
id: newActionId,
|
|
||||||
name: `액션 ${group.actions.length + 1}`,
|
|
||||||
actionType: "insert" as const,
|
|
||||||
conditions: [],
|
|
||||||
fieldMappings: [],
|
|
||||||
isEnabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
toast.success("새 액션이 추가되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
updateActionInGroup: useCallback((groupId: string, actionId: string, updates: Partial<SingleAction>) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: prev.actionGroups.map((group) =>
|
|
||||||
group.id === groupId
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
actions: group.actions.map((action) => (action.id === actionId ? { ...action, ...updates } : action)),
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
deleteActionFromGroup: useCallback((groupId: string, actionId: string) => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
actionGroups: prev.actionGroups.map((group) =>
|
|
||||||
group.id === groupId
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
actions: group.actions.filter((action) => action.id !== actionId),
|
|
||||||
}
|
|
||||||
: group,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
toast.success("액션이 삭제되었습니다.");
|
|
||||||
}, []),
|
|
||||||
|
|
||||||
// 매핑 저장 (직접 저장)
|
|
||||||
saveMappings: useCallback(async () => {
|
|
||||||
// 관계명과 설명이 없으면 저장할 수 없음
|
|
||||||
if (!state.relationshipName?.trim()) {
|
|
||||||
toast.error("관계 이름을 입력해주세요.");
|
|
||||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
|
|
||||||
if (state.connectionType === "external_call") {
|
|
||||||
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
|
|
||||||
toast.error("API URL을 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 외부호출은 테이블 정보 검증 건너뛰기
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중복 체크 (수정 모드가 아닌 경우에만)
|
|
||||||
if (!diagramId) {
|
|
||||||
try {
|
|
||||||
const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId);
|
|
||||||
if (duplicateCheck.isDuplicate) {
|
|
||||||
toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`);
|
|
||||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("중복 체크 실패:", error);
|
|
||||||
toast.error("관계명 중복 체크 중 오류가 발생했습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 실제 저장 로직 구현 - connectionType에 따라 필요한 설정만 포함
|
|
||||||
let saveData: any = {
|
|
||||||
relationshipName: state.relationshipName,
|
|
||||||
description: state.description,
|
|
||||||
connectionType: state.connectionType,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state.connectionType === "external_call") {
|
|
||||||
// 외부호출 타입인 경우: 외부호출 설정만 포함
|
|
||||||
console.log("💾 외부호출 타입 저장 - 외부호출 설정만 포함");
|
|
||||||
saveData = {
|
|
||||||
...saveData,
|
|
||||||
// 외부호출 관련 설정만 포함
|
|
||||||
externalCallConfig: state.externalCallConfig,
|
|
||||||
actionType: "external_call",
|
|
||||||
// 데이터 저장 관련 설정은 제외 (null/빈 배열로 설정)
|
|
||||||
fromConnection: null,
|
|
||||||
toConnection: null,
|
|
||||||
fromTable: null,
|
|
||||||
toTable: null,
|
|
||||||
actionGroups: [],
|
|
||||||
controlConditions: [],
|
|
||||||
actionConditions: [],
|
|
||||||
fieldMappings: [],
|
|
||||||
};
|
|
||||||
} else if (state.connectionType === "data_save") {
|
|
||||||
// 데이터 저장 타입인 경우: 데이터 저장 설정만 포함
|
|
||||||
console.log("💾 데이터 저장 타입 저장 - 데이터 저장 설정만 포함");
|
|
||||||
saveData = {
|
|
||||||
...saveData,
|
|
||||||
// 데이터 저장 관련 설정만 포함
|
|
||||||
fromConnection: state.fromConnection,
|
|
||||||
toConnection: state.toConnection,
|
|
||||||
fromTable: state.fromTable,
|
|
||||||
toTable: state.toTable,
|
|
||||||
actionGroups: state.actionGroups,
|
|
||||||
groupsLogicalOperator: state.groupsLogicalOperator,
|
|
||||||
controlConditions: state.controlConditions,
|
|
||||||
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
|
||||||
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
|
||||||
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
|
||||||
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
|
||||||
// 외부호출 관련 설정은 제외 (null로 설정)
|
|
||||||
externalCallConfig: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
|
|
||||||
|
|
||||||
// 데이터 저장 타입인 경우 기존 외부호출 설정 정리
|
|
||||||
if (state.connectionType === "data_save" && diagramId) {
|
|
||||||
console.log("🧹 데이터 저장 타입으로 변경 - 기존 외부호출 설정 정리");
|
|
||||||
try {
|
|
||||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
|
||||||
|
|
||||||
// 기존 외부호출 설정이 있는지 확인하고 삭제 또는 비활성화
|
|
||||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
|
||||||
company_code: "*",
|
|
||||||
is_active: "Y",
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingConfig = existingConfigs.data?.find(
|
|
||||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingConfig) {
|
|
||||||
console.log("🗑️ 기존 외부호출 설정 비활성화:", existingConfig.id);
|
|
||||||
// 설정을 비활성화 (삭제하지 않고 is_active를 'N'으로 변경)
|
|
||||||
await ExternalCallConfigAPI.updateConfig(existingConfig.id, {
|
|
||||||
...existingConfig,
|
|
||||||
is_active: "N",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn("⚠️ 외부호출 설정 정리 실패 (무시하고 계속):", cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외부호출인 경우에만 external-call-configs에 설정 저장
|
|
||||||
if (state.connectionType === "external_call" && state.externalCallConfig) {
|
|
||||||
try {
|
|
||||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
|
||||||
|
|
||||||
const configData = {
|
|
||||||
config_name: state.relationshipName || "외부호출 설정",
|
|
||||||
call_type: "rest-api",
|
|
||||||
api_type: "generic",
|
|
||||||
config_data: state.externalCallConfig.restApiSettings,
|
|
||||||
description: state.description || "",
|
|
||||||
company_code: "*", // 기본값
|
|
||||||
};
|
|
||||||
|
|
||||||
let configResult;
|
|
||||||
|
|
||||||
if (diagramId) {
|
|
||||||
// 수정 모드: 기존 설정이 있는지 확인하고 업데이트 또는 생성
|
|
||||||
console.log("🔄 수정 모드 - 외부호출 설정 처리");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 먼저 기존 설정 조회 시도
|
|
||||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
|
||||||
company_code: "*",
|
|
||||||
is_active: "Y",
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingConfig = existingConfigs.data?.find(
|
|
||||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingConfig) {
|
|
||||||
// 기존 설정 업데이트
|
|
||||||
console.log("📝 기존 외부호출 설정 업데이트:", existingConfig.id);
|
|
||||||
configResult = await ExternalCallConfigAPI.updateConfig(existingConfig.id, configData);
|
|
||||||
} else {
|
|
||||||
// 기존 설정이 없으면 새로 생성
|
|
||||||
console.log("🆕 새 외부호출 설정 생성 (수정 모드)");
|
|
||||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
|
||||||
}
|
|
||||||
} catch (updateError) {
|
|
||||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
|
||||||
if (updateError.message && updateError.message.includes("이미 존재합니다")) {
|
|
||||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
|
||||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
|
||||||
} else {
|
|
||||||
console.warn("⚠️ 외부호출 설정 처리 실패:", updateError);
|
|
||||||
throw updateError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 신규 생성 모드
|
|
||||||
console.log("🆕 신규 생성 모드 - 외부호출 설정 생성");
|
|
||||||
try {
|
|
||||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
|
||||||
} catch (createError) {
|
|
||||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
|
||||||
if (createError.message && createError.message.includes("이미 존재합니다")) {
|
|
||||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
|
||||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
|
||||||
} else {
|
|
||||||
throw createError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configResult.success) {
|
|
||||||
throw new Error(configResult.error || "외부호출 설정 저장 실패");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ 외부호출 설정 저장 완료:", configResult.data);
|
|
||||||
} catch (configError) {
|
|
||||||
console.error("❌ 외부호출 설정 저장 실패:", configError);
|
|
||||||
// 외부호출 설정 저장 실패해도 관계는 저장하도록 함
|
|
||||||
toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
|
|
||||||
const result = await saveDataflowRelationship(saveData, diagramId);
|
|
||||||
|
|
||||||
console.log("✅ 저장 완료:", result);
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
|
||||||
|
|
||||||
// 저장 후 닫기
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ 저장 실패:", error);
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
}, [state, diagramId, onClose]),
|
|
||||||
|
|
||||||
// 테스트 실행
|
|
||||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: 실제 테스트 로직 구현
|
|
||||||
const result: TestResult = {
|
|
||||||
success: true,
|
|
||||||
message: "테스트가 성공적으로 완료되었습니다.",
|
|
||||||
affectedRows: 10,
|
|
||||||
executionTime: 250,
|
|
||||||
};
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.success(result.message);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
<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">
|
||||||
{showBackButton && (
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
<div className="flex-shrink-0 border-b bg-white shadow-sm">
|
🎨 제어관리 - 데이터 연결 설정
|
||||||
<div className="flex items-center justify-between p-4">
|
</h1>
|
||||||
<div className="flex items-center gap-4">
|
<p className="text-gray-600 mt-1">
|
||||||
<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" ? "데이터 저장" : "외부 호출"} 연결 설정
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
|
<div className="w-[70%] bg-gray-50 flex flex-col">
|
||||||
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
|
<StepProgress
|
||||||
{/* 좌측 패널 (30%) - 항상 표시 */}
|
currentStep={state.currentStep}
|
||||||
<div className="flex w-[30%] flex-col border-r bg-white">
|
onStepChange={(step) => setState(prev => ({ ...prev, currentStep: step }))}
|
||||||
<LeftPanel state={state} actions={actions} />
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
{state.currentStep === 1 && (
|
||||||
|
<ConnectionStep
|
||||||
|
fromConnection={state.fromConnection}
|
||||||
|
toConnection={state.toConnection}
|
||||||
|
onFromConnectionChange={(conn) => setState(prev => ({ ...prev, fromConnection: conn }))}
|
||||||
|
onToConnectionChange={(conn) => setState(prev => ({ ...prev, toConnection: conn }))}
|
||||||
|
onNext={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.currentStep === 2 && (
|
||||||
|
<TableStep
|
||||||
|
fromConnection={state.fromConnection}
|
||||||
|
toConnection={state.toConnection}
|
||||||
|
fromTable={state.fromTable}
|
||||||
|
toTable={state.toTable}
|
||||||
|
onFromTableChange={(table) => setState(prev => ({ ...prev, fromTable: table }))}
|
||||||
|
onToTableChange={(table) => setState(prev => ({ ...prev, toTable: table }))}
|
||||||
|
onNext={() => setState(prev => ({ ...prev, currentStep: 3 }))}
|
||||||
|
onBack={() => setState(prev => ({ ...prev, currentStep: 1 }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.currentStep === 3 && (
|
||||||
|
<FieldMappingStep
|
||||||
|
fromTable={state.fromTable}
|
||||||
|
toTable={state.toTable}
|
||||||
|
fieldMappings={state.fieldMappings}
|
||||||
|
onMappingsChange={(mappings) => setState(prev => ({ ...prev, fieldMappings: mappings }))}
|
||||||
|
onBack={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||||
|
onSave={() => {
|
||||||
|
// 저장 로직
|
||||||
|
console.log("저장:", state);
|
||||||
|
alert("데이터 연결 설정이 저장되었습니다!");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 패널 (70%) */}
|
|
||||||
<div className="flex w-[70%] flex-col bg-gray-50">
|
|
||||||
<RightPanel key={state.connectionType} state={state} actions={actions} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,66 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
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 { Database, Globe } from "lucide-react";
|
||||||
|
import { ConnectionType } from "../types/redesigned";
|
||||||
|
|
||||||
// 타입 import
|
interface ConnectionTypeSelectorProps {
|
||||||
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
|
connectionType: "data_save" | "external_call";
|
||||||
|
onConnectionTypeChange: (type: "data_save" | "external_call") => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const connectionTypes: ConnectionType[] = [
|
||||||
* 🔘 연결 타입 선택 컴포넌트
|
{
|
||||||
* - 데이터 저장 (INSERT/UPDATE/DELETE)
|
id: "data_save",
|
||||||
* - 외부 호출 (API/Webhook)
|
label: "데이터 저장",
|
||||||
*/
|
description: "INSERT/UPDATE/DELETE 작업",
|
||||||
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
|
icon: <Database className="w-6 h-6" />,
|
||||||
const connectionTypes: ConnectionType[] = [
|
},
|
||||||
{
|
{
|
||||||
id: "data_save",
|
id: "external_call",
|
||||||
label: "데이터 저장",
|
label: "외부 호출",
|
||||||
description: "INSERT/UPDATE/DELETE 작업",
|
description: "API/Webhook 호출",
|
||||||
icon: <Database className="h-4 w-4" />,
|
icon: <Globe className="w-6 h-6" />,
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
id: "external_call",
|
|
||||||
label: "외부 호출",
|
|
||||||
description: "API/Webhook 호출",
|
|
||||||
icon: <Globe className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
||||||
|
connectionType,
|
||||||
|
onConnectionTypeChange,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="p-6 border-b border-gray-200">
|
||||||
<CardContent className="p-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
<RadioGroup
|
연결 타입 선택
|
||||||
value={selectedType}
|
</h2>
|
||||||
onValueChange={(value) => {
|
|
||||||
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
|
<div className="space-y-3">
|
||||||
onTypeChange(value as "data_save" | "external_call");
|
{connectionTypes.map((type) => (
|
||||||
}}
|
<div
|
||||||
className="space-y-3"
|
key={type.id}
|
||||||
>
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
{connectionTypes.map((type) => (
|
connectionType === type.id
|
||||||
<div key={type.id} className="flex items-start space-x-3">
|
? "border-orange-500 bg-orange-50 shadow-md"
|
||||||
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
|
: "border-gray-200 bg-white hover:border-orange-300 hover:bg-orange-25"
|
||||||
<div className="min-w-0 flex-1">
|
}`}
|
||||||
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
|
onClick={() => onConnectionTypeChange(type.id)}
|
||||||
{type.icon}
|
>
|
||||||
{type.label}
|
<div className="flex items-center gap-3">
|
||||||
</Label>
|
<div className={`p-2 rounded-lg ${
|
||||||
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
|
connectionType === type.id
|
||||||
|
? "bg-orange-100 text-orange-600"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}>
|
||||||
|
{type.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{type.label}</h3>
|
||||||
|
<p className="text-sm text-gray-600">{type.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</RadioGroup>
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConnectionTypeSelector;
|
|
||||||
|
|
@ -1,115 +1,146 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { CheckCircle, XCircle, AlertCircle, Database } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { MappingStats, FieldMapping } from "../types/redesigned";
|
||||||
import { CheckCircle, AlertTriangle, XCircle, Info } from "lucide-react";
|
|
||||||
|
|
||||||
// 타입 import
|
interface MappingInfoPanelProps {
|
||||||
import { MappingInfoPanelProps } from "../types/redesigned";
|
mappingStats: MappingStats;
|
||||||
|
fieldMappings: FieldMapping[];
|
||||||
/**
|
selectedMapping?: string;
|
||||||
* 📊 매핑 정보 패널
|
onMappingSelect: (mappingId: string) => void;
|
||||||
* - 실시간 매핑 통계
|
}
|
||||||
* - 검증 상태 표시
|
|
||||||
* - 예상 처리량 정보
|
|
||||||
*/
|
|
||||||
const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({ stats, validationErrors }) => {
|
|
||||||
const errorCount = validationErrors.filter((e) => e.type === "error").length;
|
|
||||||
const warningCount = validationErrors.filter((e) => e.type === "warning").length;
|
|
||||||
|
|
||||||
|
export const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({
|
||||||
|
mappingStats,
|
||||||
|
fieldMappings,
|
||||||
|
selectedMapping,
|
||||||
|
onMappingSelect,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="p-6">
|
||||||
<CardContent className="space-y-3 p-4">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
{/* 매핑 통계 */}
|
매핑 정보
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
</h2>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">총 매핑:</span>
|
{/* 통계 카드 */}
|
||||||
<Badge variant="outline">{stats.totalMappings}개</Badge>
|
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">유효한 매핑</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-900 mt-1">
|
||||||
<div className="flex justify-between">
|
{mappingStats.validMappings}
|
||||||
<span className="text-muted-foreground">유효한 매핑:</span>
|
|
||||||
<Badge variant="outline" className="text-green-600">
|
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
|
||||||
{stats.validMappings}개
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.invalidMappings > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">타입 불일치:</span>
|
|
||||||
<Badge variant="outline" className="text-orange-600">
|
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
||||||
{stats.invalidMappings}개
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stats.missingRequiredFields > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">필수 필드 누락:</span>
|
|
||||||
<Badge variant="outline" className="text-red-600">
|
|
||||||
<XCircle className="mr-1 h-3 w-3" />
|
|
||||||
{stats.missingRequiredFields}개
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 정보 */}
|
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
|
||||||
{stats.totalMappings > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="space-y-2 border-t pt-2">
|
<XCircle className="w-4 h-4 text-red-600" />
|
||||||
<div className="flex justify-between text-sm">
|
<span className="text-sm font-medium text-red-800">오류 매핑</span>
|
||||||
<span className="text-muted-foreground">액션:</span>
|
</div>
|
||||||
<Badge variant="secondary">{stats.actionType}</Badge>
|
<div className="text-2xl font-bold text-red-900 mt-1">
|
||||||
</div>
|
{mappingStats.invalidMappings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{stats.estimatedRows > 0 && (
|
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground">예상 처리량:</span>
|
<Database className="w-4 h-4 text-blue-600" />
|
||||||
<span className="font-medium">~{stats.estimatedRows.toLocaleString()} rows</span>
|
<span className="text-sm font-medium text-blue-800">총 매핑</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-900 mt-1">
|
||||||
|
{mappingStats.totalMappings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span className="text-sm font-medium text-yellow-800">누락 필드</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-yellow-900 mt-1">
|
||||||
|
{mappingStats.missingRequiredFields}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
필드 매핑 목록
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{fieldMappings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Database className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">아직 매핑이 없습니다</p>
|
||||||
|
<p className="text-xs">3단계에서 필드를 매핑하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{fieldMappings.map((mapping) => (
|
||||||
|
<div
|
||||||
|
key={mapping.id}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedMapping === mapping.id
|
||||||
|
? "border-orange-500 bg-orange-50"
|
||||||
|
: mapping.isValid
|
||||||
|
? "border-green-200 bg-green-50 hover:border-green-300"
|
||||||
|
: "border-red-200 bg-red-50 hover:border-red-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => onMappingSelect(mapping.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{mapping.fromField.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{mapping.toField.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
mapping.fromField.type === mapping.toField.type
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}>
|
||||||
|
{mapping.fromField.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
mapping.fromField.type === mapping.toField.type
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}>
|
||||||
|
{mapping.toField.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2">
|
||||||
|
{mapping.isValid ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapping.validationMessage && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{mapping.validationMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* 검증 오류 요약 */}
|
</div>
|
||||||
{validationErrors.length > 0 && (
|
|
||||||
<div className="border-t pt-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Info className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-muted-foreground">검증 결과:</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{errorCount > 0 && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
오류 {errorCount}개
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{warningCount > 0 && (
|
|
||||||
<Badge variant="outline" className="ml-1 text-xs text-orange-600">
|
|
||||||
경고 {warningCount}개
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 빈 상태 */}
|
|
||||||
{stats.totalMappings === 0 && (
|
|
||||||
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
||||||
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
|
||||||
<p>아직 매핑된 필드가 없습니다.</p>
|
|
||||||
<p className="mt-1 text-xs">우측에서 연결을 설정해주세요.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Database 아이콘 import 추가
|
|
||||||
import { Database } from "lucide-react";
|
|
||||||
|
|
||||||
export default MappingInfoPanel;
|
|
||||||
|
|
@ -1,416 +1,187 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState } from "react";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Database, ArrowRight, CheckCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Connection } from "../types/redesigned";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { ArrowRight, Database, Globe, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
// API import
|
|
||||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
|
||||||
import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
|
||||||
|
|
||||||
// 타입 import
|
|
||||||
import { Connection } from "@/lib/types/multiConnection";
|
|
||||||
|
|
||||||
interface ConnectionStepProps {
|
interface ConnectionStepProps {
|
||||||
connectionType: "data_save" | "external_call";
|
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
toConnection?: Connection;
|
toConnection?: Connection;
|
||||||
relationshipName?: string;
|
onFromConnectionChange: (connection: Connection) => void;
|
||||||
description?: string;
|
onToConnectionChange: (connection: Connection) => void;
|
||||||
diagramId?: number; // 🔧 수정 모드 감지용
|
|
||||||
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
|
||||||
onSetRelationshipName: (name: string) => void;
|
|
||||||
onSetDescription: (description: string) => void;
|
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 임시 연결 데이터 (실제로는 API에서 가져올 것)
|
||||||
* 🔗 1단계: 연결 선택
|
const mockConnections: Connection[] = [
|
||||||
* - FROM/TO 데이터베이스 연결 선택
|
{
|
||||||
* - 연결 상태 표시
|
id: "conn1",
|
||||||
* - 지연시간 정보
|
name: "메인 데이터베이스",
|
||||||
*/
|
type: "PostgreSQL",
|
||||||
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
host: "localhost",
|
||||||
({
|
port: 5432,
|
||||||
connectionType,
|
database: "main_db",
|
||||||
fromConnection,
|
username: "admin",
|
||||||
toConnection,
|
tables: []
|
||||||
relationshipName,
|
|
||||||
description,
|
|
||||||
diagramId,
|
|
||||||
onSelectConnection,
|
|
||||||
onSetRelationshipName,
|
|
||||||
onSetDescription,
|
|
||||||
onNext,
|
|
||||||
}) => {
|
|
||||||
const [connections, setConnections] = useState<Connection[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle");
|
|
||||||
|
|
||||||
// API 응답을 Connection 타입으로 변환
|
|
||||||
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
|
||||||
id: connectionInfo.id,
|
|
||||||
name: connectionInfo.connection_name,
|
|
||||||
type: connectionInfo.db_type,
|
|
||||||
host: connectionInfo.host,
|
|
||||||
port: connectionInfo.port,
|
|
||||||
database: connectionInfo.database_name,
|
|
||||||
username: connectionInfo.username,
|
|
||||||
isActive: connectionInfo.is_active === "Y",
|
|
||||||
companyCode: connectionInfo.company_code,
|
|
||||||
createdDate: connectionInfo.created_date,
|
|
||||||
updatedDate: connectionInfo.updated_date,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔍 관계명 중복 체크 (디바운스 적용)
|
|
||||||
const checkNameDuplicate = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setNameCheckStatus("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNameCheckStatus("checking");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await checkRelationshipNameDuplicate(name, diagramId);
|
|
||||||
setNameCheckStatus(result.isDuplicate ? "duplicate" : "valid");
|
|
||||||
|
|
||||||
if (result.isDuplicate) {
|
|
||||||
toast.warning(`"${name}" 이름이 이미 사용 중입니다. (${result.duplicateCount}개 발견)`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("중복 체크 실패:", error);
|
|
||||||
setNameCheckStatus("idle");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[diagramId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 관계명 변경 시 중복 체크 (디바운스)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!relationshipName) {
|
|
||||||
setNameCheckStatus("idle");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
checkNameDuplicate(relationshipName);
|
|
||||||
}, 500); // 500ms 디바운스
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [relationshipName, checkNameDuplicate]);
|
|
||||||
|
|
||||||
// 연결 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConnections = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const data = await getActiveConnections();
|
|
||||||
|
|
||||||
// 메인 DB 연결 추가
|
|
||||||
const mainConnection: Connection = {
|
|
||||||
id: 0,
|
|
||||||
name: "메인 데이터베이스",
|
|
||||||
type: "postgresql",
|
|
||||||
host: "localhost",
|
|
||||||
port: 5432,
|
|
||||||
database: "main",
|
|
||||||
username: "main_user",
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// API 응답을 Connection 타입으로 변환
|
|
||||||
const convertedConnections = data.map(convertToConnection);
|
|
||||||
|
|
||||||
// 중복 방지: 기존에 메인 연결이 없는 경우에만 추가
|
|
||||||
const hasMainConnection = convertedConnections.some((conn) => conn.id === 0);
|
|
||||||
const preliminaryConnections = hasMainConnection
|
|
||||||
? convertedConnections
|
|
||||||
: [mainConnection, ...convertedConnections];
|
|
||||||
|
|
||||||
// ID 중복 제거 (Set 사용)
|
|
||||||
const uniqueConnections = preliminaryConnections.filter(
|
|
||||||
(conn, index, arr) => arr.findIndex((c) => c.id === conn.id) === index,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("🔗 연결 목록 로드 완료:", uniqueConnections);
|
|
||||||
setConnections(uniqueConnections);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 연결 목록 로드 실패:", error);
|
|
||||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
|
||||||
|
|
||||||
// 에러 시에도 메인 연결은 제공
|
|
||||||
const mainConnection: Connection = {
|
|
||||||
id: 0,
|
|
||||||
name: "메인 데이터베이스",
|
|
||||||
type: "postgresql",
|
|
||||||
host: "localhost",
|
|
||||||
port: 5432,
|
|
||||||
database: "main",
|
|
||||||
username: "main_user",
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
setConnections([mainConnection]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadConnections();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConnectionSelect = (type: "from" | "to", connectionId: string) => {
|
|
||||||
const connection = connections.find((c) => c.id.toString() === connectionId);
|
|
||||||
if (connection) {
|
|
||||||
onSelectConnection(type, connection);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canProceed = fromConnection && toConnection;
|
|
||||||
|
|
||||||
const getConnectionIcon = (connection: Connection) => {
|
|
||||||
return connection.id === 0 ? <Database className="h-4 w-4" /> : <Globe className="h-4 w-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConnectionBadge = (connection: Connection) => {
|
|
||||||
if (connection.id === 0) {
|
|
||||||
return (
|
|
||||||
<Badge variant="default" className="text-xs">
|
|
||||||
메인 DB
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{connection.type?.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Database className="h-5 w-5" />
|
|
||||||
1단계: 연결 선택
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{connectionType === "data_save"
|
|
||||||
? "데이터를 저장할 소스와 대상 데이터베이스를 선택하세요."
|
|
||||||
: "외부 호출을 위한 소스와 대상 연결을 선택하세요."}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
|
||||||
{/* 관계 정보 입력 */}
|
|
||||||
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
|
|
||||||
<h3 className="font-medium">관계 정보</h3>
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="relationshipName"
|
|
||||||
placeholder="예: 사용자 데이터 동기화"
|
|
||||||
value={relationshipName || ""}
|
|
||||||
onChange={(e) => onSetRelationshipName(e.target.value)}
|
|
||||||
className={`pr-10 ${
|
|
||||||
nameCheckStatus === "duplicate"
|
|
||||||
? "border-red-500 focus:border-red-500"
|
|
||||||
: nameCheckStatus === "valid"
|
|
||||||
? "border-green-500 focus:border-green-500"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="absolute top-1/2 right-3 -translate-y-1/2">
|
|
||||||
{nameCheckStatus === "checking" && (
|
|
||||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
{nameCheckStatus === "valid" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
|
||||||
{nameCheckStatus === "duplicate" && <AlertTriangle className="h-4 w-4 text-red-500" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{nameCheckStatus === "duplicate" && <p className="text-sm text-red-600">이미 사용 중인 이름입니다.</p>}
|
|
||||||
{nameCheckStatus === "valid" && <p className="text-sm text-green-600">사용 가능한 이름입니다.</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">설명</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="이 관계에 대한 설명을 입력하세요"
|
|
||||||
value={description || ""}
|
|
||||||
onChange={(e) => onSetDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
|
||||||
<span>연결 목록을 불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* FROM 연결 선택 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-medium">FROM 연결 (소스)</h3>
|
|
||||||
{fromConnection && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-green-600">
|
|
||||||
🟢 연결됨
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs">지연시간: ~23ms</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={fromConnection?.id.toString() || ""}
|
|
||||||
onValueChange={(value) => handleConnectionSelect("from", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{connections.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
|
||||||
) : (
|
|
||||||
connections.map((connection, index) => (
|
|
||||||
<SelectItem key={`from_${connection.id}_${index}`} value={connection.id.toString()}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getConnectionIcon(connection)}
|
|
||||||
<span>{connection.name}</span>
|
|
||||||
{getConnectionBadge(connection)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{fromConnection && (
|
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
{getConnectionIcon(fromConnection)}
|
|
||||||
<span className="font-medium">{fromConnection.name}</span>
|
|
||||||
{getConnectionBadge(fromConnection)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground space-y-1 text-xs">
|
|
||||||
<p>
|
|
||||||
호스트: {fromConnection.host}:{fromConnection.port}
|
|
||||||
</p>
|
|
||||||
<p>데이터베이스: {fromConnection.database}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TO 연결 선택 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-medium">TO 연결 (대상)</h3>
|
|
||||||
{toConnection && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-green-600">
|
|
||||||
🟢 연결됨
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs">지연시간: ~45ms</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={toConnection?.id.toString() || ""}
|
|
||||||
onValueChange={(value) => handleConnectionSelect("to", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{connections.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
|
||||||
) : (
|
|
||||||
connections.map((connection, index) => (
|
|
||||||
<SelectItem key={`to_${connection.id}_${index}`} value={connection.id.toString()}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getConnectionIcon(connection)}
|
|
||||||
<span>{connection.name}</span>
|
|
||||||
{getConnectionBadge(connection)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{toConnection && (
|
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
{getConnectionIcon(toConnection)}
|
|
||||||
<span className="font-medium">{toConnection.name}</span>
|
|
||||||
{getConnectionBadge(toConnection)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground space-y-1 text-xs">
|
|
||||||
<p>
|
|
||||||
호스트: {toConnection.host}:{toConnection.port}
|
|
||||||
</p>
|
|
||||||
<p>데이터베이스: {toConnection.database}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 연결 매핑 표시 */}
|
|
||||||
{fromConnection && toConnection && (
|
|
||||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">{fromConnection.name}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">소스</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRight className="text-primary h-5 w-5" />
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">{toConnection.name}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">대상</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 text-center">
|
|
||||||
<Badge variant="outline" className="text-primary">
|
|
||||||
💡 연결 매핑 설정 완료
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 다음 단계 버튼 */}
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
|
||||||
다음: 테이블 선택
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
{
|
||||||
|
id: "conn2",
|
||||||
|
name: "외부 API",
|
||||||
|
type: "REST API",
|
||||||
|
host: "api.example.com",
|
||||||
|
port: 443,
|
||||||
|
database: "external",
|
||||||
|
username: "api_user",
|
||||||
|
tables: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "conn3",
|
||||||
|
name: "백업 데이터베이스",
|
||||||
|
type: "MySQL",
|
||||||
|
host: "backup.local",
|
||||||
|
port: 3306,
|
||||||
|
database: "backup_db",
|
||||||
|
username: "backup_user",
|
||||||
|
tables: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
ConnectionStep.displayName = "ConnectionStep";
|
export const ConnectionStep: React.FC<ConnectionStepProps> = ({
|
||||||
|
fromConnection,
|
||||||
|
toConnection,
|
||||||
|
onFromConnectionChange,
|
||||||
|
onToConnectionChange,
|
||||||
|
onNext,
|
||||||
|
}) => {
|
||||||
|
const [selectedFrom, setSelectedFrom] = useState<string>(fromConnection?.id || "");
|
||||||
|
const [selectedTo, setSelectedTo] = useState<string>(toConnection?.id || "");
|
||||||
|
|
||||||
export default ConnectionStep;
|
const handleFromSelect = (connectionId: string) => {
|
||||||
|
const connection = mockConnections.find(c => c.id === connectionId);
|
||||||
|
if (connection) {
|
||||||
|
setSelectedFrom(connectionId);
|
||||||
|
onFromConnectionChange(connection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToSelect = (connectionId: string) => {
|
||||||
|
const connection = mockConnections.find(c => c.id === connectionId);
|
||||||
|
if (connection) {
|
||||||
|
setSelectedTo(connectionId);
|
||||||
|
onToConnectionChange(connection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canProceed = selectedFrom && selectedTo && selectedFrom !== selectedTo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
연결 선택
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
데이터를 가져올 연결과 저장할 연결을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* FROM 연결 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-blue-600 font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">FROM 연결</h3>
|
||||||
|
<span className="text-sm text-gray-500">(데이터 소스)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockConnections.map((connection) => (
|
||||||
|
<div
|
||||||
|
key={connection.id}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedFrom === connection.id
|
||||||
|
? "border-blue-500 bg-blue-50 shadow-md"
|
||||||
|
: "border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-25"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleFromSelect(connection.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="w-6 h-6 text-blue-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900">{connection.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600">{connection.type}</p>
|
||||||
|
<p className="text-xs text-gray-500">{connection.host}:{connection.port}</p>
|
||||||
|
</div>
|
||||||
|
{selectedFrom === connection.id && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 화살표 */}
|
||||||
|
<div className="hidden lg:flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||||
|
<ArrowRight className="w-6 h-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TO 연결 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-green-600 font-bold">2</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">TO 연결</h3>
|
||||||
|
<span className="text-sm text-gray-500">(데이터 대상)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockConnections.map((connection) => (
|
||||||
|
<div
|
||||||
|
key={connection.id}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedTo === connection.id
|
||||||
|
? "border-green-500 bg-green-50 shadow-md"
|
||||||
|
: "border-gray-200 bg-white hover:border-green-300 hover:bg-green-25"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToSelect(connection.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="w-6 h-6 text-green-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900">{connection.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600">{connection.type}</p>
|
||||||
|
<p className="text-xs text-gray-500">{connection.host}:{connection.port}</p>
|
||||||
|
</div>
|
||||||
|
{selectedTo === connection.id && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className={`px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||||
|
canProceed
|
||||||
|
? "bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg"
|
||||||
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
다음 단계: 테이블 선택
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,199 +1,232 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { ArrowLeft, Save, CheckCircle, XCircle, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { TableInfo, FieldMapping, ColumnInfo } from "../types/redesigned";
|
||||||
import { ArrowLeft, Link, Loader2, CheckCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
// API import
|
|
||||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
// 타입 import
|
|
||||||
import { Connection, TableInfo, ColumnInfo } from "@/lib/types/multiConnection";
|
|
||||||
import { FieldMapping } from "../types/redesigned";
|
|
||||||
|
|
||||||
// 컴포넌트 import
|
|
||||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
|
||||||
|
|
||||||
interface FieldMappingStepProps {
|
interface FieldMappingStepProps {
|
||||||
fromTable?: TableInfo;
|
fromTable?: TableInfo;
|
||||||
toTable?: TableInfo;
|
toTable?: TableInfo;
|
||||||
fromConnection?: Connection;
|
|
||||||
toConnection?: Connection;
|
|
||||||
fieldMappings: FieldMapping[];
|
fieldMappings: FieldMapping[];
|
||||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
onMappingsChange: (mappings: FieldMapping[]) => void;
|
||||||
onDeleteMapping: (mappingId: string) => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onSave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||||
* 🎯 3단계: 시각적 필드 매핑
|
|
||||||
* - SVG 기반 연결선 표시
|
|
||||||
* - 드래그 앤 드롭 지원 (향후)
|
|
||||||
* - 실시간 매핑 업데이트
|
|
||||||
*/
|
|
||||||
const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
|
||||||
fromTable,
|
fromTable,
|
||||||
toTable,
|
toTable,
|
||||||
fromConnection,
|
|
||||||
toConnection,
|
|
||||||
fieldMappings,
|
fieldMappings,
|
||||||
onCreateMapping,
|
onMappingsChange,
|
||||||
onDeleteMapping,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
onBack,
|
||||||
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// 컬럼 정보 로드
|
const createMapping = (fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||||
useEffect(() => {
|
const mapping: FieldMapping = {
|
||||||
const loadColumns = async () => {
|
id: `${fromField.name}-${toField.name}`,
|
||||||
console.log("🔍 컬럼 로딩 시작:", {
|
fromField,
|
||||||
fromConnection: fromConnection?.id,
|
toField,
|
||||||
toConnection: toConnection?.id,
|
isValid: fromField.type === toField.type,
|
||||||
fromTable: fromTable?.tableName,
|
validationMessage: fromField.type !== toField.type ? "타입이 다릅니다" : undefined
|
||||||
toTable: toTable?.tableName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
|
||||||
console.warn("⚠️ 필수 정보 누락:", {
|
|
||||||
fromConnection: !!fromConnection,
|
|
||||||
toConnection: !!toConnection,
|
|
||||||
fromTable: !!fromTable,
|
|
||||||
toTable: !!toTable,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
console.log("📡 API 호출 시작:", {
|
|
||||||
fromAPI: `getColumnsFromConnection(${fromConnection.id}, "${fromTable.tableName}")`,
|
|
||||||
toAPI: `getColumnsFromConnection(${toConnection.id}, "${toTable.tableName}")`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [fromCols, toCols] = await Promise.all([
|
|
||||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
|
||||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log("🔍 원본 API 응답 확인:", {
|
|
||||||
fromCols: fromCols,
|
|
||||||
toCols: toCols,
|
|
||||||
fromType: typeof fromCols,
|
|
||||||
toType: typeof toCols,
|
|
||||||
fromIsArray: Array.isArray(fromCols),
|
|
||||||
toIsArray: Array.isArray(toCols),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 안전한 배열 처리
|
|
||||||
const safeFromCols = Array.isArray(fromCols) ? fromCols : [];
|
|
||||||
const safeToCols = Array.isArray(toCols) ? toCols : [];
|
|
||||||
|
|
||||||
console.log("✅ 컬럼 로딩 성공:", {
|
|
||||||
fromColumns: safeFromCols.length,
|
|
||||||
toColumns: safeToCols.length,
|
|
||||||
fromData: safeFromCols.slice(0, 2), // 처음 2개만 로깅
|
|
||||||
toData: safeToCols.slice(0, 2),
|
|
||||||
originalFromType: typeof fromCols,
|
|
||||||
originalToType: typeof toCols,
|
|
||||||
});
|
|
||||||
|
|
||||||
setFromColumns(safeFromCols);
|
|
||||||
setToColumns(safeToCols);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
|
||||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColumns();
|
const newMappings = [...fieldMappings, mapping];
|
||||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
onMappingsChange(newMappings);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
const removeMapping = (mappingId: string) => {
|
||||||
return (
|
const newMappings = fieldMappings.filter(m => m.id !== mappingId);
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
onMappingsChange(newMappings);
|
||||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
};
|
||||||
<span>필드 정보를 불러오는 중...</span>
|
|
||||||
</CardContent>
|
const handleDragStart = (field: ColumnInfo) => {
|
||||||
);
|
setDraggedField(field);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, toField: ColumnInfo) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedField) {
|
||||||
|
createMapping(draggedField, toField);
|
||||||
|
setDraggedField(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMappedFromField = (toFieldName: string) => {
|
||||||
|
return fieldMappings.find(m => m.toField.name === toFieldName)?.fromField;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFieldMapped = (fieldName: string) => {
|
||||||
|
return fieldMappings.some(m => m.fromField.name === fieldName || m.toField.name === fieldName);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-8">
|
||||||
<CardHeader className="pb-2">
|
<div className="text-center">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
<Link className="h-5 w-5" />
|
필드 매핑
|
||||||
3단계: 컬럼 매핑
|
</h2>
|
||||||
</CardTitle>
|
<p className="text-gray-600">
|
||||||
</CardHeader>
|
소스 테이블의 필드를 대상 테이블의 필드에 드래그하여 매핑하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex h-full flex-col p-0">
|
{/* 매핑 통계 */}
|
||||||
{/* 매핑 캔버스 - 전체 영역 사용 */}
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div className="min-h-0 flex-1 p-4">
|
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||||
{isLoading ? (
|
<div className="text-2xl font-bold text-blue-900">{fieldMappings.length}</div>
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="text-sm text-blue-700">총 매핑</div>
|
||||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
|
||||||
</div>
|
|
||||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
|
||||||
<FieldMappingCanvas
|
|
||||||
fromFields={fromColumns}
|
|
||||||
toFields={toColumns}
|
|
||||||
mappings={fieldMappings}
|
|
||||||
onCreateMapping={onCreateMapping}
|
|
||||||
onDeleteMapping={onDeleteMapping}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-3">
|
|
||||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
console.log("🔄 수동 재로딩 시도");
|
|
||||||
setFromColumns([]);
|
|
||||||
setToColumns([]);
|
|
||||||
// useEffect가 재실행되도록 강제 업데이트
|
|
||||||
setIsLoading(true);
|
|
||||||
setTimeout(() => setIsLoading(false), 100);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||||
|
<div className="text-2xl font-bold text-green-900">
|
||||||
|
{fieldMappings.filter(m => m.isValid).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-700">유효한 매핑</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||||
|
<div className="text-2xl font-bold text-red-900">
|
||||||
|
{fieldMappings.filter(m => !m.isValid).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-red-700">오류 매핑</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||||
|
<div className="text-2xl font-bold text-yellow-900">
|
||||||
|
{(toTable?.columns.length || 0) - fieldMappings.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-yellow-700">미매핑 필드</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 하단 네비게이션 - 고정 */}
|
{/* 매핑 영역 */}
|
||||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="flex items-center justify-between">
|
{/* FROM 테이블 필드들 */}
|
||||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
<div className="space-y-4">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
이전
|
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
</Button>
|
<span className="text-blue-600 font-bold text-sm">FROM</span>
|
||||||
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
|
|
||||||
</div>
|
</div>
|
||||||
|
{fromTable?.name} 필드들
|
||||||
<Button onClick={onNext} disabled={fieldMappings.length === 0} className="flex items-center gap-2">
|
</h3>
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
저장
|
<div className="space-y-2">
|
||||||
</Button>
|
{fromTable?.columns.map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(field)}
|
||||||
|
className={`p-3 rounded-lg border-2 cursor-move transition-all duration-200 ${
|
||||||
|
isFieldMapped(field.name)
|
||||||
|
? "border-green-300 bg-green-50 opacity-60"
|
||||||
|
: "border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{field.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">{field.type}</div>
|
||||||
|
{field.primaryKey && (
|
||||||
|
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||||
|
PK
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isFieldMapped(field.name) && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FieldMappingStep;
|
{/* TO 테이블 필드들 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-green-600 font-bold text-sm">TO</span>
|
||||||
|
</div>
|
||||||
|
{toTable?.name} 필드들
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{toTable?.columns.map((field) => {
|
||||||
|
const mappedFromField = getMappedFromField(field.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, field)}
|
||||||
|
className={`p-3 rounded-lg border-2 transition-all duration-200 ${
|
||||||
|
mappedFromField
|
||||||
|
? "border-green-300 bg-green-50"
|
||||||
|
: "border-gray-200 bg-gray-50 hover:border-green-300 hover:bg-green-25"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{field.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">{field.type}</div>
|
||||||
|
{field.primaryKey && (
|
||||||
|
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||||
|
PK
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mappedFromField && (
|
||||||
|
<div className="text-xs text-green-700 mt-1">
|
||||||
|
← {mappedFromField.name} ({mappedFromField.type})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{mappedFromField && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeMapping(`${mappedFromField.name}-${field.name}`)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{mappedFromField && (
|
||||||
|
<div>
|
||||||
|
{fieldMappings.find(m => m.toField.name === field.name)?.isValid ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼들 */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
이전 단계
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
연결 설정 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,90 +1,70 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Check, ArrowRight } from "lucide-react";
|
||||||
import { CheckCircle, Circle, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
// 타입 import
|
interface StepProgressProps {
|
||||||
import { StepProgressProps } from "../types/redesigned";
|
currentStep: 1 | 2 | 3;
|
||||||
|
onStepChange: (step: 1 | 2 | 3) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const steps = [
|
||||||
* 📊 단계 진행 표시
|
{ id: 1, title: "연결 선택", description: "FROM/TO 연결 설정" },
|
||||||
* - 현재 단계 하이라이트
|
{ id: 2, title: "테이블 선택", description: "소스/타겟 테이블 선택" },
|
||||||
* - 완료된 단계 체크 표시
|
{ id: 3, title: "필드 매핑", description: "시각적 필드 매핑" },
|
||||||
* - 클릭으로 단계 이동
|
];
|
||||||
*/
|
|
||||||
const StepProgress: React.FC<StepProgressProps> = ({ currentStep, completedSteps, onStepClick }) => {
|
|
||||||
const steps = [
|
|
||||||
{ number: 1, title: "연결 선택", description: "FROM/TO 데이터베이스 연결" },
|
|
||||||
{ number: 2, title: "테이블 선택", description: "소스/대상 테이블 선택" },
|
|
||||||
{ number: 3, title: "제어 조건", description: "전체 제어 실행 조건 설정" },
|
|
||||||
{ number: 4, title: "액션 및 매핑", description: "액션 설정 및 컬럼 매핑" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getStepStatus = (stepNumber: number) => {
|
|
||||||
if (completedSteps.includes(stepNumber)) return "completed";
|
|
||||||
if (stepNumber === currentStep) return "current";
|
|
||||||
return "pending";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepIcon = (stepNumber: number) => {
|
|
||||||
const status = getStepStatus(stepNumber);
|
|
||||||
|
|
||||||
if (status === "completed") {
|
|
||||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Circle className={`h-5 w-5 ${status === "current" ? "text-primary fill-primary" : "text-muted-foreground"}`} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canClickStep = (stepNumber: number) => {
|
|
||||||
// 현재 단계이거나 완료된 단계만 클릭 가능
|
|
||||||
return stepNumber === currentStep || completedSteps.includes(stepNumber);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export const StepProgress: React.FC<StepProgressProps> = ({
|
||||||
|
currentStep,
|
||||||
|
onStepChange,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
{steps.map((step, index) => (
|
<div className="flex items-center justify-between">
|
||||||
<React.Fragment key={step.number}>
|
{steps.map((step, index) => (
|
||||||
{/* 단계 */}
|
<React.Fragment key={step.id}>
|
||||||
<div className="flex items-center">
|
<div
|
||||||
<Button
|
className={`flex items-center gap-3 cursor-pointer transition-all duration-200 ${
|
||||||
variant="ghost"
|
step.id <= currentStep ? "opacity-100" : "opacity-50"
|
||||||
className={`flex h-auto items-center gap-3 p-3 ${
|
|
||||||
canClickStep(step.number) ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => canClickStep(step.number) && onStepClick(step.number as 1 | 2 | 3 | 4 | 5)}
|
onClick={() => step.id <= currentStep && onStepChange(step.id as 1 | 2 | 3)}
|
||||||
disabled={!canClickStep(step.number)}
|
|
||||||
>
|
>
|
||||||
{/* 아이콘 */}
|
<div
|
||||||
<div className="flex-shrink-0">{getStepIcon(step.number)}</div>
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
|
||||||
|
step.id < currentStep
|
||||||
{/* 텍스트 */}
|
? "bg-green-500 text-white"
|
||||||
<div className="text-left">
|
: step.id === currentStep
|
||||||
<div
|
? "bg-orange-500 text-white"
|
||||||
className={`text-sm font-medium ${
|
: "bg-gray-200 text-gray-600"
|
||||||
getStepStatus(step.number) === "current"
|
}`}
|
||||||
? "text-primary"
|
>
|
||||||
: getStepStatus(step.number) === "completed"
|
{step.id < currentStep ? (
|
||||||
? "text-foreground"
|
<Check className="w-4 h-4" />
|
||||||
: "text-muted-foreground"
|
) : (
|
||||||
}`}
|
step.id
|
||||||
>
|
)}
|
||||||
{step.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs">{step.description}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
<div>
|
||||||
|
<h3 className={`text-sm font-medium ${
|
||||||
{/* 화살표 (마지막 단계 제외) */}
|
step.id <= currentStep ? "text-gray-900" : "text-gray-500"
|
||||||
{index < steps.length - 1 && <ArrowRight className="text-muted-foreground mx-2 h-4 w-4" />}
|
}`}>
|
||||||
</React.Fragment>
|
{step.title}
|
||||||
))}
|
</h3>
|
||||||
|
<p className={`text-xs ${
|
||||||
|
step.id <= currentStep ? "text-gray-600" : "text-gray-400"
|
||||||
|
}`}>
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<ArrowRight className="w-4 h-4 text-gray-400 mx-4" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StepProgress;
|
|
||||||
|
|
@ -1,343 +1,212 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Table, ArrowLeft, ArrowRight, CheckCircle, Database } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Connection, TableInfo } from "../types/redesigned";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ArrowLeft, ArrowRight, Table, Search, Loader2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
// API import
|
|
||||||
import { getTablesFromConnection, getBatchTablesWithColumns } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
// 타입 import
|
|
||||||
import { Connection, TableInfo } from "@/lib/types/multiConnection";
|
|
||||||
|
|
||||||
interface TableStepProps {
|
interface TableStepProps {
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
toConnection?: Connection;
|
toConnection?: Connection;
|
||||||
fromTable?: TableInfo;
|
fromTable?: TableInfo;
|
||||||
toTable?: TableInfo;
|
toTable?: TableInfo;
|
||||||
onSelectTable: (type: "from" | "to", table: TableInfo) => void;
|
onFromTableChange: (table: TableInfo) => void;
|
||||||
|
onToTableChange: (table: TableInfo) => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 임시 테이블 데이터
|
||||||
* 📋 2단계: 테이블 선택
|
const mockTables: TableInfo[] = [
|
||||||
* - FROM/TO 테이블 선택
|
{
|
||||||
* - 테이블 검색 기능
|
name: "users",
|
||||||
* - 컬럼 수 정보 표시
|
schema: "public",
|
||||||
*/
|
columns: [
|
||||||
const TableStep: React.FC<TableStepProps> = ({
|
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||||
|
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
|
||||||
|
{ name: "email", type: "varchar", nullable: true, primaryKey: false },
|
||||||
|
{ name: "created_at", type: "timestamp", nullable: false, primaryKey: false }
|
||||||
|
],
|
||||||
|
rowCount: 1250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orders",
|
||||||
|
schema: "public",
|
||||||
|
columns: [
|
||||||
|
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||||
|
{ name: "user_id", type: "integer", nullable: false, primaryKey: false, foreignKey: true },
|
||||||
|
{ name: "product_name", type: "varchar", nullable: false, primaryKey: false },
|
||||||
|
{ name: "amount", type: "decimal", nullable: false, primaryKey: false },
|
||||||
|
{ name: "order_date", type: "timestamp", nullable: false, primaryKey: false }
|
||||||
|
],
|
||||||
|
rowCount: 3420
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "products",
|
||||||
|
schema: "public",
|
||||||
|
columns: [
|
||||||
|
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||||
|
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
|
||||||
|
{ name: "price", type: "decimal", nullable: false, primaryKey: false },
|
||||||
|
{ name: "category", type: "varchar", nullable: true, primaryKey: false }
|
||||||
|
],
|
||||||
|
rowCount: 156
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TableStep: React.FC<TableStepProps> = ({
|
||||||
fromConnection,
|
fromConnection,
|
||||||
toConnection,
|
toConnection,
|
||||||
fromTable,
|
fromTable,
|
||||||
toTable,
|
toTable,
|
||||||
onSelectTable,
|
onFromTableChange,
|
||||||
|
onToTableChange,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const [fromTables, setFromTables] = useState<TableInfo[]>([]);
|
const [selectedFromTable, setSelectedFromTable] = useState<string>(fromTable?.name || "");
|
||||||
const [toTables, setToTables] = useState<TableInfo[]>([]);
|
const [selectedToTable, setSelectedToTable] = useState<string>(toTable?.name || "");
|
||||||
const [fromSearch, setFromSearch] = useState("");
|
|
||||||
const [toSearch, setToSearch] = useState("");
|
|
||||||
const [isLoadingFrom, setIsLoadingFrom] = useState(false);
|
|
||||||
const [isLoadingTo, setIsLoadingTo] = useState(false);
|
|
||||||
const [tableColumnCounts, setTableColumnCounts] = useState<Record<string, number>>({});
|
|
||||||
|
|
||||||
// FROM 테이블 목록 로드 (배치 조회)
|
const handleFromTableSelect = (tableName: string) => {
|
||||||
useEffect(() => {
|
const table = mockTables.find(t => t.name === tableName);
|
||||||
if (fromConnection) {
|
|
||||||
const loadFromTables = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoadingFrom(true);
|
|
||||||
console.log("🚀 FROM 테이블 배치 조회 시작");
|
|
||||||
|
|
||||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
|
||||||
const batchResult = await getBatchTablesWithColumns(fromConnection.id);
|
|
||||||
|
|
||||||
console.log("✅ FROM 테이블 배치 조회 완료:", batchResult);
|
|
||||||
|
|
||||||
// TableInfo 형식으로 변환
|
|
||||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
|
||||||
tableName: item.tableName,
|
|
||||||
displayName: item.displayName || item.tableName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setFromTables(tables);
|
|
||||||
|
|
||||||
// 컬럼 수 정보를 state에 저장
|
|
||||||
const columnCounts: Record<string, number> = {};
|
|
||||||
batchResult.forEach((item) => {
|
|
||||||
columnCounts[`from_${item.tableName}`] = item.columnCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTableColumnCounts((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...columnCounts,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`📊 FROM 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
|
||||||
toast.error("소스 테이블 목록을 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoadingFrom(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadFromTables();
|
|
||||||
}
|
|
||||||
}, [fromConnection]);
|
|
||||||
|
|
||||||
// TO 테이블 목록 로드 (배치 조회)
|
|
||||||
useEffect(() => {
|
|
||||||
if (toConnection) {
|
|
||||||
const loadToTables = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoadingTo(true);
|
|
||||||
console.log("🚀 TO 테이블 배치 조회 시작");
|
|
||||||
|
|
||||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
|
||||||
const batchResult = await getBatchTablesWithColumns(toConnection.id);
|
|
||||||
|
|
||||||
console.log("✅ TO 테이블 배치 조회 완료:", batchResult);
|
|
||||||
|
|
||||||
// TableInfo 형식으로 변환
|
|
||||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
|
||||||
tableName: item.tableName,
|
|
||||||
displayName: item.displayName || item.tableName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setToTables(tables);
|
|
||||||
|
|
||||||
// 컬럼 수 정보를 state에 저장
|
|
||||||
const columnCounts: Record<string, number> = {};
|
|
||||||
batchResult.forEach((item) => {
|
|
||||||
columnCounts[`to_${item.tableName}`] = item.columnCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTableColumnCounts((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...columnCounts,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`📊 TO 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("TO 테이블 목록 로드 실패:", error);
|
|
||||||
toast.error("대상 테이블 목록을 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadToTables();
|
|
||||||
}
|
|
||||||
}, [toConnection]);
|
|
||||||
|
|
||||||
// 테이블 필터링
|
|
||||||
const filteredFromTables = fromTables.filter((table) =>
|
|
||||||
(table.displayName || table.tableName).toLowerCase().includes(fromSearch.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredToTables = toTables.filter((table) =>
|
|
||||||
(table.displayName || table.tableName).toLowerCase().includes(toSearch.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTableSelect = (type: "from" | "to", tableName: string) => {
|
|
||||||
const tables = type === "from" ? fromTables : toTables;
|
|
||||||
const table = tables.find((t) => t.tableName === tableName);
|
|
||||||
if (table) {
|
if (table) {
|
||||||
onSelectTable(type, table);
|
setSelectedFromTable(tableName);
|
||||||
|
onFromTableChange(table);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canProceed = fromTable && toTable;
|
const handleToTableSelect = (tableName: string) => {
|
||||||
|
const table = mockTables.find(t => t.name === tableName);
|
||||||
const renderTableItem = (table: TableInfo, type: "from" | "to") => {
|
if (table) {
|
||||||
const displayName =
|
setSelectedToTable(tableName);
|
||||||
table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName;
|
onToTableChange(table);
|
||||||
|
}
|
||||||
const columnCount = tableColumnCounts[`${type}_${table.tableName}`];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Table className="h-4 w-4" />
|
|
||||||
<span>{displayName}</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{columnCount !== undefined ? columnCount : table.columnCount || 0}개 컬럼
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canProceed = selectedFromTable && selectedToTable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-8">
|
||||||
<CardHeader>
|
<div className="text-center">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
<Table className="h-5 w-5" />
|
테이블 선택
|
||||||
2단계: 테이블 선택
|
</h2>
|
||||||
</CardTitle>
|
<p className="text-gray-600">
|
||||||
<p className="text-muted-foreground text-sm">연결된 데이터베이스에서 소스와 대상 테이블을 선택하세요.</p>
|
소스 테이블과 대상 테이블을 선택하세요
|
||||||
</CardHeader>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
{/* 연결 정보 표시 */}
|
||||||
{/* FROM 테이블 선택 */}
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-medium">FROM 테이블 (소스)</h3>
|
<Database className="w-5 h-5 text-blue-600" />
|
||||||
<Badge variant="outline" className="text-xs">
|
<span className="font-medium text-gray-900">{fromConnection?.name}</span>
|
||||||
{fromConnection?.name}
|
<span className="text-sm text-gray-500">→</span>
|
||||||
</Badge>
|
<Database className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="font-medium text-gray-900">{toConnection?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="테이블 검색..."
|
|
||||||
value={fromSearch}
|
|
||||||
onChange={(e) => setFromSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
|
||||||
{isLoadingFrom ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Select value={fromTable?.tableName || ""} onValueChange={(value) => handleTableSelect("from", value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromTables.map((table) => (
|
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
|
||||||
{renderTableItem(table, "from")}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fromTable && (
|
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="font-medium">{fromTable.displayName || fromTable.tableName}</span>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{fromTable.description && <p className="text-muted-foreground text-xs">{fromTable.description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* TO 테이블 선택 */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="space-y-3">
|
{/* FROM 테이블 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-4">
|
||||||
<h3 className="font-medium">TO 테이블 (대상)</h3>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
{toConnection?.name}
|
<span className="text-blue-600 font-bold">1</span>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="테이블 검색..."
|
|
||||||
value={toSearch}
|
|
||||||
onChange={(e) => setToSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
|
||||||
{isLoadingTo ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<h3 className="text-lg font-semibold text-gray-900">소스 테이블</h3>
|
||||||
<Select value={toTable?.tableName || ""} onValueChange={(value) => handleTableSelect("to", value)}>
|
<span className="text-sm text-gray-500">(FROM)</span>
|
||||||
<SelectTrigger>
|
</div>
|
||||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
<div className="space-y-2">
|
||||||
<SelectContent>
|
{mockTables.map((table) => (
|
||||||
{filteredToTables.map((table) => (
|
<div
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
key={table.name}
|
||||||
{renderTableItem(table, "to")}
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
</SelectItem>
|
selectedFromTable === table.name
|
||||||
))}
|
? "border-blue-500 bg-blue-50 shadow-md"
|
||||||
</SelectContent>
|
: "border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-25"
|
||||||
</Select>
|
}`}
|
||||||
)}
|
onClick={() => handleFromTableSelect(table.name)}
|
||||||
|
>
|
||||||
{toTable && (
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
<Table className="w-6 h-6 text-blue-600" />
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="flex-1">
|
||||||
<span className="font-medium">{toTable.displayName || toTable.tableName}</span>
|
<h4 className="font-medium text-gray-900">{table.name}</h4>
|
||||||
<Badge variant="secondary">
|
<p className="text-sm text-gray-600">{table.columns.length}개 컬럼</p>
|
||||||
📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()}개 행</p>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
{selectedFromTable === table.name && (
|
||||||
{toTable.description && <p className="text-muted-foreground text-xs">{toTable.description}</p>}
|
<CheckCircle className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 매핑 표시 */}
|
|
||||||
{fromTable && toTable && (
|
|
||||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">{fromTable.displayName || fromTable.tableName}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ArrowRight className="text-primary h-5 w-5" />
|
{/* TO 테이블 */}
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="text-center">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium">{toTable.displayName || toTable.tableName}</div>
|
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
<div className="text-muted-foreground text-xs">
|
<span className="text-green-600 font-bold">2</span>
|
||||||
{tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">대상 테이블</h3>
|
||||||
|
<span className="text-sm text-gray-500">(TO)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockTables.map((table) => (
|
||||||
|
<div
|
||||||
|
key={table.name}
|
||||||
|
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedToTable === table.name
|
||||||
|
? "border-green-500 bg-green-50 shadow-md"
|
||||||
|
: "border-gray-200 bg-white hover:border-green-300 hover:bg-green-25"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToTableSelect(table.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Table className="w-6 h-6 text-green-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900">{table.name}</h4>
|
||||||
|
<p className="text-sm text-gray-600">{table.columns.length}개 컬럼</p>
|
||||||
|
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()}개 행</p>
|
||||||
|
</div>
|
||||||
|
{selectedToTable === table.name && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
<div className="mt-3 text-center">
|
|
||||||
<Badge variant="outline" className="text-primary">
|
|
||||||
💡 테이블 매핑: {fromTable.displayName || fromTable.tableName} →{" "}
|
|
||||||
{toTable.displayName || toTable.tableName}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 네비게이션 버튼 */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
이전: 연결 선택
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
|
||||||
다음: 컬럼 매핑
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</>
|
|
||||||
|
{/* 버튼들 */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
이전 단계
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||||
|
canProceed
|
||||||
|
? "bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg"
|
||||||
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
다음 단계: 필드 매핑
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TableStep;
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
// 🎨 제어관리 UI 재설계 - 타입 정의
|
|
||||||
|
|
||||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
|
||||||
|
|
||||||
// 연결 타입
|
|
||||||
export interface ConnectionType {
|
export interface ConnectionType {
|
||||||
id: "data_save" | "external_call";
|
id: "data_save" | "external_call";
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -10,7 +5,6 @@ export interface ConnectionType {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 매핑
|
|
||||||
export interface FieldMapping {
|
export interface FieldMapping {
|
||||||
id: string;
|
id: string;
|
||||||
fromField: ColumnInfo;
|
fromField: ColumnInfo;
|
||||||
|
|
@ -20,18 +14,33 @@ export interface FieldMapping {
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시각적 연결선
|
export interface ColumnInfo {
|
||||||
export interface MappingLine {
|
name: string;
|
||||||
id: string;
|
type: string;
|
||||||
fromX: number;
|
nullable: boolean;
|
||||||
fromY: number;
|
primaryKey: boolean;
|
||||||
toX: number;
|
foreignKey?: boolean;
|
||||||
toY: number;
|
defaultValue?: any;
|
||||||
isValid: boolean;
|
}
|
||||||
isHovered: boolean;
|
|
||||||
|
export interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
rowCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
tables: TableInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 매핑 통계
|
|
||||||
export interface MappingStats {
|
export interface MappingStats {
|
||||||
totalMappings: number;
|
totalMappings: number;
|
||||||
validMappings: number;
|
validMappings: number;
|
||||||
|
|
@ -41,58 +50,16 @@ export interface MappingStats {
|
||||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검증 결과
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
id: string;
|
field: string;
|
||||||
type: "error" | "warning" | "info";
|
|
||||||
message: string;
|
message: string;
|
||||||
fieldId?: string;
|
severity: "error" | "warning" | "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
errors: ValidationError[];
|
|
||||||
warnings: ValidationError[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 테스트 결과
|
|
||||||
export interface TestResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
affectedRows?: number;
|
|
||||||
executionTime?: number;
|
|
||||||
errors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 액션 정의
|
|
||||||
export interface SingleAction {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
actionType: "insert" | "update" | "delete" | "upsert";
|
|
||||||
conditions: any[];
|
|
||||||
fieldMappings: any[];
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 액션 그룹 (AND/OR 조건으로 연결)
|
|
||||||
export interface ActionGroup {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
logicalOperator: "AND" | "OR";
|
|
||||||
actions: SingleAction[];
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메인 상태
|
|
||||||
export interface DataConnectionState {
|
export interface DataConnectionState {
|
||||||
// 기본 설정
|
// 기본 설정
|
||||||
connectionType: "data_save" | "external_call";
|
connectionType: "data_save" | "external_call";
|
||||||
currentStep: 1 | 2 | 3 | 4;
|
currentStep: 1 | 2 | 3;
|
||||||
|
|
||||||
// 관계 정보
|
|
||||||
diagramId?: number; // 🔧 수정 모드 감지용
|
|
||||||
relationshipName?: string;
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
// 연결 정보
|
// 연결 정보
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
|
|
@ -104,141 +71,8 @@ export interface DataConnectionState {
|
||||||
fieldMappings: FieldMapping[];
|
fieldMappings: FieldMapping[];
|
||||||
mappingStats: MappingStats;
|
mappingStats: MappingStats;
|
||||||
|
|
||||||
// 제어 실행 조건 (전체 제어가 언제 실행될지)
|
|
||||||
controlConditions: any[]; // 전체 제어 트리거 조건
|
|
||||||
|
|
||||||
// 액션 설정 (멀티 액션 지원)
|
|
||||||
actionGroups: ActionGroup[];
|
|
||||||
groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자
|
|
||||||
|
|
||||||
// 외부호출 설정
|
|
||||||
externalCallConfig?: {
|
|
||||||
restApiSettings: {
|
|
||||||
apiUrl: string;
|
|
||||||
httpMethod: string;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
bodyTemplate: string;
|
|
||||||
authentication: {
|
|
||||||
type: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
timeout: number;
|
|
||||||
retryCount: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 기존 호환성을 위한 필드들 (deprecated)
|
|
||||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
|
||||||
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
|
||||||
actionFieldMappings?: any[]; // 액션별 필드 매핑
|
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
selectedMapping?: string;
|
selectedMapping?: string;
|
||||||
fromColumns?: ColumnInfo[]; // 🔧 FROM 테이블 컬럼 정보 (중앙 관리)
|
|
||||||
toColumns?: ColumnInfo[]; // 🔧 TO 테이블 컬럼 정보 (중앙 관리)
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
validationErrors: ValidationError[];
|
validationErrors: ValidationError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 액션 인터페이스
|
|
||||||
export interface DataConnectionActions {
|
|
||||||
// 연결 타입
|
|
||||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
|
||||||
|
|
||||||
// 관계 정보
|
|
||||||
setRelationshipName: (name: string) => void;
|
|
||||||
setDescription: (description: string) => void;
|
|
||||||
setGroupsLogicalOperator: (operator: "AND" | "OR") => void;
|
|
||||||
|
|
||||||
// 단계 진행
|
|
||||||
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
|
||||||
|
|
||||||
// 연결/테이블 선택
|
|
||||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
|
||||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
|
||||||
|
|
||||||
// 컬럼 정보 로드 (중앙 관리)
|
|
||||||
loadColumns: () => Promise<void>;
|
|
||||||
|
|
||||||
// 필드 매핑
|
|
||||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
|
||||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
|
||||||
deleteMapping: (mappingId: string) => void;
|
|
||||||
|
|
||||||
// 제어 조건 관리 (전체 실행 조건)
|
|
||||||
addControlCondition: () => void;
|
|
||||||
updateControlCondition: (index: number, condition: any) => void;
|
|
||||||
deleteControlCondition: (index: number) => void;
|
|
||||||
|
|
||||||
// 외부호출 설정 관리
|
|
||||||
updateExternalCallConfig: (config: any) => void;
|
|
||||||
|
|
||||||
// 액션 그룹 관리 (멀티 액션)
|
|
||||||
addActionGroup: () => void;
|
|
||||||
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
|
||||||
deleteActionGroup: (groupId: string) => void;
|
|
||||||
addActionToGroup: (groupId: string) => void;
|
|
||||||
updateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
|
||||||
deleteActionFromGroup: (groupId: string, actionId: string) => void;
|
|
||||||
|
|
||||||
// 기존 액션 설정 (호환성)
|
|
||||||
setActionType: (type: "insert" | "update" | "delete" | "upsert") => void;
|
|
||||||
addActionCondition: () => void;
|
|
||||||
updateActionCondition: (index: number, condition: any) => void;
|
|
||||||
setActionConditions: (conditions: any[]) => void; // 액션 조건 배열 전체 업데이트
|
|
||||||
deleteActionCondition: (index: number) => void;
|
|
||||||
|
|
||||||
// 검증 및 저장
|
|
||||||
validateMappings: () => Promise<ValidationResult>;
|
|
||||||
saveMappings: () => Promise<void>;
|
|
||||||
testExecution: () => Promise<TestResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 Props 타입들
|
|
||||||
export interface DataConnectionDesignerProps {
|
|
||||||
onClose?: () => void;
|
|
||||||
initialData?: Partial<DataConnectionState>;
|
|
||||||
showBackButton?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeftPanelProps {
|
|
||||||
state: DataConnectionState;
|
|
||||||
actions: DataConnectionActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RightPanelProps {
|
|
||||||
state: DataConnectionState;
|
|
||||||
actions: DataConnectionActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionTypeSelectorProps {
|
|
||||||
selectedType: "data_save" | "external_call";
|
|
||||||
onTypeChange: (type: "data_save" | "external_call") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MappingInfoPanelProps {
|
|
||||||
stats: MappingStats;
|
|
||||||
validationErrors: ValidationError[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MappingDetailListProps {
|
|
||||||
mappings: FieldMapping[];
|
|
||||||
selectedMapping?: string;
|
|
||||||
onSelectMapping: (mappingId: string) => void;
|
|
||||||
onUpdateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
|
||||||
onDeleteMapping: (mappingId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepProgressProps {
|
|
||||||
currentStep: 1 | 2 | 3 | 4;
|
|
||||||
completedSteps: number[];
|
|
||||||
onStepClick: (step: 1 | 2 | 3 | 4) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FieldMappingCanvasProps {
|
|
||||||
fromFields: ColumnInfo[];
|
|
||||||
toFields: ColumnInfo[];
|
|
||||||
mappings: FieldMapping[];
|
|
||||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
|
||||||
onDeleteMapping: (mappingId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||||||
|
|
||||||
|
interface ResponsiveContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
mobileClassName?: string;
|
||||||
|
tabletClassName?: string;
|
||||||
|
desktopClassName?: string;
|
||||||
|
breakpoint?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveContainer: React.FC<ResponsiveContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
mobileClassName = "",
|
||||||
|
tabletClassName = "",
|
||||||
|
desktopClassName = "",
|
||||||
|
breakpoint = "md",
|
||||||
|
}) => {
|
||||||
|
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||||
|
|
||||||
|
const getResponsiveClassName = () => {
|
||||||
|
let responsiveClass = className;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
responsiveClass += ` ${mobileClassName}`;
|
||||||
|
} else if (isTablet) {
|
||||||
|
responsiveClass += ` ${tabletClassName}`;
|
||||||
|
} else if (isDesktop) {
|
||||||
|
responsiveClass += ` ${desktopClassName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responsiveClass.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={getResponsiveClassName()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResponsiveGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
cols?: {
|
||||||
|
mobile?: number;
|
||||||
|
tablet?: number;
|
||||||
|
desktop?: number;
|
||||||
|
};
|
||||||
|
gap?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveGrid: React.FC<ResponsiveGridProps> = ({
|
||||||
|
children,
|
||||||
|
cols = { mobile: 1, tablet: 2, desktop: 3 },
|
||||||
|
gap = "4",
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||||
|
|
||||||
|
const getGridCols = () => {
|
||||||
|
if (isMobile) return `grid-cols-${cols.mobile || 1}`;
|
||||||
|
if (isTablet) return `grid-cols-${cols.tablet || 2}`;
|
||||||
|
if (isDesktop) return `grid-cols-${cols.desktop || 3}`;
|
||||||
|
return "grid-cols-1";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid ${getGridCols()} gap-${gap} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResponsiveTextProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: {
|
||||||
|
mobile?: string;
|
||||||
|
tablet?: string;
|
||||||
|
desktop?: string;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResponsiveText: React.FC<ResponsiveTextProps> = ({
|
||||||
|
children,
|
||||||
|
size = { mobile: "text-sm", tablet: "text-base", desktop: "text-lg" },
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||||
|
|
||||||
|
const getTextSize = () => {
|
||||||
|
if (isMobile) return size.mobile || "text-sm";
|
||||||
|
if (isTablet) return size.tablet || "text-base";
|
||||||
|
if (isDesktop) return size.desktop || "text-lg";
|
||||||
|
return "text-base";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${getTextSize()} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ConfirmDeleteModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
itemName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDeleteModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
itemName,
|
||||||
|
}: ConfirmDeleteModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-gradient-to-r from-red-500 to-red-600 px-6 py-4 flex items-center justify-between rounded-t-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-white" />
|
||||||
|
<h2 className="text-xl font-bold text-white">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<p className="text-gray-700">{message}</p>
|
||||||
|
{itemName && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm font-medium text-red-800">
|
||||||
|
삭제 대상: <span className="font-bold">{itemName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3 px-6 pb-6">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1 bg-red-500 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Mail, Server, Lock, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
MailAccount,
|
||||||
|
CreateMailAccountDto,
|
||||||
|
UpdateMailAccountDto,
|
||||||
|
testMailConnection,
|
||||||
|
} from '@/lib/api/mail';
|
||||||
|
|
||||||
|
interface MailAccountModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: CreateMailAccountDto | UpdateMailAccountDto) => Promise<void>;
|
||||||
|
account?: MailAccount | null;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailAccountModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
account,
|
||||||
|
mode,
|
||||||
|
}: MailAccountModalProps) {
|
||||||
|
const [formData, setFormData] = useState<CreateMailAccountDto>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUsername: '',
|
||||||
|
smtpPassword: '',
|
||||||
|
dailyLimit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 수정 모드일 때 기존 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && account) {
|
||||||
|
setFormData({
|
||||||
|
name: account.name,
|
||||||
|
email: account.email,
|
||||||
|
smtpHost: account.smtpHost,
|
||||||
|
smtpPort: account.smtpPort,
|
||||||
|
smtpSecure: account.smtpSecure,
|
||||||
|
smtpUsername: account.smtpUsername,
|
||||||
|
smtpPassword: '', // 비밀번호는 비워둠 (보안)
|
||||||
|
dailyLimit: account.dailyLimit,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 생성 모드일 때 초기화
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUsername: '',
|
||||||
|
smtpPassword: '',
|
||||||
|
dailyLimit: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTestResult(null);
|
||||||
|
}, [mode, account, isOpen]);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]:
|
||||||
|
type === 'number'
|
||||||
|
? parseInt(value)
|
||||||
|
: type === 'checkbox'
|
||||||
|
? (e.target as HTMLInputElement).checked
|
||||||
|
: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!account?.id && mode === 'edit') return;
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 수정 모드에서만 테스트 가능 (저장된 계정만)
|
||||||
|
if (mode === 'edit' && account) {
|
||||||
|
const result = await testMailConnection(account.id);
|
||||||
|
setTestResult(result);
|
||||||
|
} else {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: '계정을 먼저 저장한 후 테스트할 수 있습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '연결 테스트 실패',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(formData);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 실패:', error);
|
||||||
|
alert(error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-6 h-6 text-white" />
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{mode === 'create' ? '새 메일 계정 추가' : '메일 계정 수정'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-orange-500" />
|
||||||
|
기본 정보
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
계정명 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="예: 회사 공식 메일"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
발신 이메일 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="info@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMTP 설정 */}
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5 text-orange-500" />
|
||||||
|
SMTP 서버 설정
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
SMTP 호스트 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpHost"
|
||||||
|
value={formData.smtpHost}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
예: smtp.gmail.com, smtp.naver.com, smtp.office365.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
SMTP 포트 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="smtpPort"
|
||||||
|
value={formData.smtpPort}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="587"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
일반적으로 587 (TLS) 또는 465 (SSL)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
보안 연결
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="smtpSecure"
|
||||||
|
value={formData.smtpSecure ? 'true' : 'false'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
smtpSecure: e.target.value === 'true',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
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="false">TLS (포트 587)</option>
|
||||||
|
<option value="true">SSL (포트 465)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증 정보 */}
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
<Lock className="w-5 h-5 text-orange-500" />
|
||||||
|
인증 정보
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
사용자명 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpUsername"
|
||||||
|
value={formData.smtpUsername}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="info@company.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
대부분 이메일 주소와 동일
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
비밀번호 {mode === 'edit' && '(변경 시에만 입력)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="smtpPassword"
|
||||||
|
value={formData.smtpPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={mode === 'create'}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder={mode === 'edit' ? '변경하지 않으려면 비워두세요' : '••••••••'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Gmail의 경우 앱 비밀번호 사용 권장
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 발송 제한 */}
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-orange-500" />
|
||||||
|
발송 설정
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
일일 발송 제한
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dailyLimit"
|
||||||
|
value={formData.dailyLimit}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||||
|
placeholder="1000"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
하루에 발송 가능한 최대 메일 수 (0 = 제한 없음)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연결 테스트 (수정 모드만) */}
|
||||||
|
{mode === 'edit' && account && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="w-full bg-blue-500 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
연결 테스트 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
SMTP 연결 테스트
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`mt-3 p-3 rounded-lg flex items-start gap-2 ${
|
||||||
|
testResult.success
|
||||||
|
? 'bg-green-50 border border-green-200'
|
||||||
|
: 'bg-red-50 border border-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.success ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
testResult.success ? 'text-green-800' : 'text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'저장'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
Zap,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MailAccount } from '@/lib/api/mail';
|
||||||
|
|
||||||
|
interface MailAccountTableProps {
|
||||||
|
accounts: MailAccount[];
|
||||||
|
onEdit: (account: MailAccount) => void;
|
||||||
|
onDelete: (account: MailAccount) => void;
|
||||||
|
onToggleStatus: (account: MailAccount) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailAccountTable({
|
||||||
|
accounts,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
}: MailAccountTableProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<keyof MailAccount>('createdAt');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
const filteredAccounts = accounts.filter(
|
||||||
|
(account) =>
|
||||||
|
account.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
account.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
account.smtpHost.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortedAccounts = [...filteredAccounts].sort((a, b) => {
|
||||||
|
const aValue = a[sortField];
|
||||||
|
const bValue = b[sortField];
|
||||||
|
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
return sortOrder === 'asc'
|
||||||
|
? aValue.localeCompare(bValue)
|
||||||
|
: bValue.localeCompare(aValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (field: keyof MailAccount) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||||
|
<Mail className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-lg font-medium text-gray-600 mb-2">
|
||||||
|
등록된 메일 계정이 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
"새 계정 추가" 버튼을 클릭하여 첫 번째 메일 계정을 등록하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="계정명, 이메일, 서버로 검색..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-orange-500" />
|
||||||
|
계정명
|
||||||
|
{sortField === 'name' && (
|
||||||
|
<span className="text-xs">
|
||||||
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||||
|
onClick={() => handleSort('email')}
|
||||||
|
>
|
||||||
|
이메일 주소
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||||
|
SMTP 서버
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||||
|
onClick={() => handleSort('status')}
|
||||||
|
>
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||||
|
onClick={() => handleSort('dailyLimit')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-orange-500" />
|
||||||
|
일일 제한
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||||
|
onClick={() => handleSort('createdAt')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-orange-500" />
|
||||||
|
생성일
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-center text-sm font-semibold text-gray-700">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{sortedAccounts.map((account) => (
|
||||||
|
<tr
|
||||||
|
key={account.id}
|
||||||
|
className="hover:bg-orange-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900">{account.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-600">{account.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{account.smtpHost}:{account.smtpPort}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{account.smtpSecure ? 'SSL' : 'TLS'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleStatus(account)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all hover:scale-105 ${
|
||||||
|
account.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{account.status === 'active' ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
활성
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
비활성
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{account.dailyLimit > 0
|
||||||
|
? account.dailyLimit.toLocaleString()
|
||||||
|
: '무제한'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{formatDate(account.createdAt)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(account)}
|
||||||
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(account)}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 요약 */}
|
||||||
|
<div className="text-sm text-gray-600 text-center">
|
||||||
|
전체 {accounts.length}개 중 {sortedAccounts.length}개 표시
|
||||||
|
{searchTerm && ` (검색: "${searchTerm}")`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Type,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Square,
|
||||||
|
MousePointer,
|
||||||
|
Eye,
|
||||||
|
Send,
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Settings
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface MailComponent {
|
||||||
|
id: string;
|
||||||
|
type: "text" | "button" | "image" | "spacer" | "table";
|
||||||
|
content?: string;
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
src?: string;
|
||||||
|
height?: number;
|
||||||
|
styles?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sql: string;
|
||||||
|
parameters: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value?: any;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailDesignerProps {
|
||||||
|
templateId?: string;
|
||||||
|
onSave?: (data: any) => void;
|
||||||
|
onPreview?: (data: any) => void;
|
||||||
|
onSend?: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailDesigner({
|
||||||
|
templateId,
|
||||||
|
onSave,
|
||||||
|
onPreview,
|
||||||
|
onSend,
|
||||||
|
}: MailDesignerProps) {
|
||||||
|
const [components, setComponents] = useState<MailComponent[]>([]);
|
||||||
|
const [selectedComponent, setSelectedComponent] = useState<string | null>(null);
|
||||||
|
const [templateName, setTemplateName] = useState("");
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||||
|
|
||||||
|
// 컴포넌트 타입 정의
|
||||||
|
const componentTypes = [
|
||||||
|
{ type: "text", icon: Type, label: "텍스트", color: "bg-blue-100 hover:bg-blue-200" },
|
||||||
|
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" },
|
||||||
|
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||||
|
{ type: "spacer", icon: Square, label: "여백", color: "bg-gray-100 hover:bg-gray-200" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 컴포넌트 추가
|
||||||
|
const addComponent = (type: string) => {
|
||||||
|
const newComponent: MailComponent = {
|
||||||
|
id: `comp-${Date.now()}`,
|
||||||
|
type: type as any,
|
||||||
|
content: type === "text" ? "<p>텍스트를 입력하세요...</p>" : undefined,
|
||||||
|
text: type === "button" ? "버튼" : undefined,
|
||||||
|
url: type === "button" || type === "image" ? "https://example.com" : undefined,
|
||||||
|
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=Image" : undefined,
|
||||||
|
height: type === "spacer" ? 20 : undefined,
|
||||||
|
styles: {
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||||
|
color: type === "button" ? "#fff" : "#333",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setComponents([...components, newComponent]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 삭제
|
||||||
|
const removeComponent = (id: string) => {
|
||||||
|
setComponents(components.filter(c => c.id !== id));
|
||||||
|
if (selectedComponent === id) {
|
||||||
|
setSelectedComponent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 선택
|
||||||
|
const selectComponent = (id: string) => {
|
||||||
|
setSelectedComponent(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 내용 업데이트
|
||||||
|
const updateComponent = (id: string, updates: Partial<MailComponent>) => {
|
||||||
|
setComponents(
|
||||||
|
components.map(c => c.id === id ? { ...c, ...updates } : c)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = () => {
|
||||||
|
const data = {
|
||||||
|
name: templateName,
|
||||||
|
subject,
|
||||||
|
components,
|
||||||
|
queries,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
onSave(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 미리보기
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (onPreview) {
|
||||||
|
onPreview({ components, subject });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 발송
|
||||||
|
const handleSend = () => {
|
||||||
|
if (onSend) {
|
||||||
|
onSend({ components, subject, queries });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 가져오기
|
||||||
|
const selected = components.find(c => c.id === selectedComponent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50">
|
||||||
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||||
|
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
||||||
|
<Mail className="w-4 h-4 mr-2 text-orange-500" />
|
||||||
|
컴포넌트
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
onClick={() => addComponent(type)}
|
||||||
|
variant="outline"
|
||||||
|
className={`w-full justify-start ${color} border-gray-300`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-2" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 템플릿 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<CardTitle className="text-sm">템플릿 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-2 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">템플릿 이름</Label>
|
||||||
|
<Input
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
placeholder="예: 고객 환영 메일"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">제목</Label>
|
||||||
|
<Input
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="예: {customer_name}님 환영합니다!"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button onClick={handleSave} className="w-full" variant="default">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePreview} className="w-full" variant="outline">
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
미리보기
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSend} className="w-full bg-orange-500 hover:bg-orange-600 text-white">
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
발송
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 캔버스 */}
|
||||||
|
<div className="flex-1 p-8 overflow-y-auto">
|
||||||
|
<Card className="max-w-3xl mx-auto">
|
||||||
|
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 border-b">
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>메일 미리보기</span>
|
||||||
|
<span className="text-sm text-gray-500 font-normal">
|
||||||
|
{components.length}개 컴포넌트
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* 제목 영역 */}
|
||||||
|
{subject && (
|
||||||
|
<div className="p-6 bg-gray-50 border-b">
|
||||||
|
<p className="text-sm text-gray-500">제목:</p>
|
||||||
|
<p className="font-semibold text-lg">{subject}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 렌더링 */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{components.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||||
|
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
components.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
onClick={() => selectComponent(comp.id)}
|
||||||
|
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||||
|
selectedComponent === comp.id
|
||||||
|
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||||
|
: "hover:ring-2 hover:ring-gray-300"
|
||||||
|
}`}
|
||||||
|
style={comp.styles}
|
||||||
|
>
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeComponent(comp.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
{comp.type === "text" && (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: comp.content || "" }} />
|
||||||
|
)}
|
||||||
|
{comp.type === "button" && (
|
||||||
|
<a
|
||||||
|
href={comp.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block px-6 py-3 rounded-md"
|
||||||
|
style={comp.styles}
|
||||||
|
>
|
||||||
|
{comp.text}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{comp.type === "image" && (
|
||||||
|
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||||
|
)}
|
||||||
|
{comp.type === "spacer" && (
|
||||||
|
<div style={{ height: `${comp.height}px` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 속성 패널 */}
|
||||||
|
<div className="w-80 bg-white border-l p-4 overflow-y-auto">
|
||||||
|
{selected ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 flex items-center">
|
||||||
|
<Settings className="w-4 h-4 mr-2 text-orange-500" />
|
||||||
|
속성 편집
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSelectedComponent(null)}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 텍스트 컴포넌트 */}
|
||||||
|
{selected.type === "text" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">내용 (HTML)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={selected.content || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, { content: e.target.value })
|
||||||
|
}
|
||||||
|
rows={8}
|
||||||
|
className="mt-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 컴포넌트 */}
|
||||||
|
{selected.type === "button" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.text || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, { text: e.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">링크 URL</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.url || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, { url: e.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">배경색</Label>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={selected.styles?.backgroundColor || "#007bff"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, {
|
||||||
|
styles: { ...selected.styles, backgroundColor: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지 컴포넌트 */}
|
||||||
|
{selected.type === "image" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">이미지 URL</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.src || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, { src: e.target.value })
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 여백 컴포넌트 */}
|
||||||
|
{selected.type === "spacer" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={selected.height || 20}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateComponent(selected.id, { height: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
<Settings className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||||
|
<p className="text-sm">컴포넌트를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Paperclip,
|
||||||
|
Reply,
|
||||||
|
Forward,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { MailDetail, getMailDetail, markMailAsRead } from "@/lib/api/mail";
|
||||||
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
|
||||||
|
interface MailDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
accountId: string;
|
||||||
|
mailId: string; // "accountId-seqno" 형식
|
||||||
|
onMailRead?: () => void; // 읽음 처리 후 목록 갱신용
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailDetailModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
accountId,
|
||||||
|
mailId,
|
||||||
|
onMailRead,
|
||||||
|
}: MailDetailModalProps) {
|
||||||
|
const [mail, setMail] = useState<MailDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showHtml, setShowHtml] = useState(true); // HTML/텍스트 토글
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && mailId) {
|
||||||
|
loadMailDetail();
|
||||||
|
}
|
||||||
|
}, [isOpen, mailId]);
|
||||||
|
|
||||||
|
const loadMailDetail = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// mailId에서 seqno 추출 (예: "account123-45" -> 45)
|
||||||
|
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||||
|
|
||||||
|
if (isNaN(seqno)) {
|
||||||
|
throw new Error("유효하지 않은 메일 ID입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메일 상세 조회
|
||||||
|
const mailDetail = await getMailDetail(accountId, seqno);
|
||||||
|
setMail(mailDetail);
|
||||||
|
|
||||||
|
// 읽음 처리
|
||||||
|
if (!mailDetail.isRead) {
|
||||||
|
await markMailAsRead(accountId, seqno);
|
||||||
|
onMailRead?.(); // 목록 갱신
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("메일 상세 조회 실패:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "메일을 불러오는데 실패했습니다."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeHtml = (html: string) => {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
"p",
|
||||||
|
"br",
|
||||||
|
"strong",
|
||||||
|
"em",
|
||||||
|
"u",
|
||||||
|
"a",
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"img",
|
||||||
|
"div",
|
||||||
|
"span",
|
||||||
|
"table",
|
||||||
|
"tr",
|
||||||
|
"td",
|
||||||
|
"th",
|
||||||
|
"thead",
|
||||||
|
"tbody",
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ["href", "src", "alt", "title", "style", "class"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAttachment = async (index: number, filename: string) => {
|
||||||
|
try {
|
||||||
|
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||||
|
|
||||||
|
// 다운로드 URL
|
||||||
|
const downloadUrl = `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||||
|
|
||||||
|
// 다운로드 트리거
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('첨부파일 다운로드 실패:', err);
|
||||||
|
alert('첨부파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between pr-6">
|
||||||
|
<span className="text-xl font-bold truncate">메일 상세</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
<Button onClick={loadMailDetail} variant="outline" className="mt-4">
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : mail ? (
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4">
|
||||||
|
{/* 메일 헤더 */}
|
||||||
|
<div className="border-b pb-4 space-y-2">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
|
{mail.subject}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">보낸사람:</span>{" "}
|
||||||
|
<span className="text-gray-900">{mail.from}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">받는사람:</span>{" "}
|
||||||
|
<span className="text-gray-600">{mail.to}</span>
|
||||||
|
</div>
|
||||||
|
{mail.cc && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">참조:</span>{" "}
|
||||||
|
<span className="text-gray-600">{mail.cc}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">날짜:</span>{" "}
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{formatDate(mail.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Reply className="w-4 h-4 mr-2" />
|
||||||
|
답장
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Forward className="w-4 h-4 mr-2" />
|
||||||
|
전달
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부파일 */}
|
||||||
|
{mail.attachments && mail.attachments.length > 0 && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Paperclip className="w-4 h-4 text-gray-600" />
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
첨부파일 ({mail.attachments.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mail.attachments.map((attachment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between bg-white rounded px-3 py-2 border hover:border-orange-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{attachment.filename}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadAttachment(index, attachment.filename)}
|
||||||
|
className="hover:bg-orange-50 hover:text-orange-600"
|
||||||
|
>
|
||||||
|
다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTML/텍스트 토글 */}
|
||||||
|
{mail.htmlBody && mail.textBody && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={showHtml ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHtml(true)}
|
||||||
|
className={
|
||||||
|
showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
HTML 보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!showHtml ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHtml(false)}
|
||||||
|
className={
|
||||||
|
!showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
텍스트 보기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메일 본문 */}
|
||||||
|
<div className="border rounded-lg p-6 bg-white min-h-[300px]">
|
||||||
|
{showHtml && mail.htmlBody ? (
|
||||||
|
<div
|
||||||
|
className="prose max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizeHtml(mail.htmlBody),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-800">
|
||||||
|
{mail.textBody || "본문 내용이 없습니다."}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Mail, Edit2, Trash2, Eye, Copy, Calendar } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MailTemplate } from '@/lib/api/mail';
|
||||||
|
|
||||||
|
interface MailTemplateCardProps {
|
||||||
|
template: MailTemplate;
|
||||||
|
onEdit: (template: MailTemplate) => void;
|
||||||
|
onDelete: (template: MailTemplate) => void;
|
||||||
|
onPreview: (template: MailTemplate) => void;
|
||||||
|
onDuplicate?: (template: MailTemplate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailTemplateCard({
|
||||||
|
template,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onDuplicate,
|
||||||
|
}: MailTemplateCardProps) {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category?: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
welcome: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||||
|
promotion: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||||
|
notification: 'bg-green-100 text-green-700 border-green-300',
|
||||||
|
newsletter: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||||
|
system: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||||
|
};
|
||||||
|
return colors[category || ''] || 'bg-gray-100 text-gray-700 border-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-gradient-to-r from-orange-50 to-amber-50 p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
<div className="p-2 bg-white rounded-lg shadow-sm">
|
||||||
|
<Mail className="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 truncate mt-1">
|
||||||
|
{template.subject}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{template.category && (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full border ${getCategoryColor(
|
||||||
|
template.category
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{template.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 미리보기 */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200 min-h-[100px]">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">컴포넌트 {template.components.length}개</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{template.components.slice(0, 3).map((component, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-orange-400" />
|
||||||
|
<span className="capitalize">{component.type}</span>
|
||||||
|
{component.type === 'text' && component.content && (
|
||||||
|
<span className="text-gray-400 truncate flex-1">
|
||||||
|
{component.content.replace(/<[^>]*>/g, '').substring(0, 30)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{template.components.length > 3 && (
|
||||||
|
<p className="text-xs text-gray-400 pl-3.5">
|
||||||
|
+{template.components.length - 3}개 더보기
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메타 정보 */}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 pt-2 border-t">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>{formatDate(template.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{template.updatedAt !== template.createdAt && (
|
||||||
|
<span className="text-gray-400">수정됨</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="p-4 pt-0 flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
||||||
|
onClick={() => onPreview(template)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
미리보기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 hover:bg-green-50 hover:text-green-600 hover:border-green-300"
|
||||||
|
onClick={() => onEdit(template)}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-1" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
{onDuplicate && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-purple-50 hover:text-purple-600 hover:border-purple-300"
|
||||||
|
onClick={() => onDuplicate(template)}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-red-50 hover:text-red-600 hover:border-red-300"
|
||||||
|
onClick={() => onDelete(template)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Save, Eye } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MailTemplate, CreateMailTemplateDto, UpdateMailTemplateDto } from '@/lib/api/mail';
|
||||||
|
import MailDesigner from './MailDesigner';
|
||||||
|
|
||||||
|
interface MailTemplateEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: CreateMailTemplateDto | UpdateMailTemplateDto) => Promise<void>;
|
||||||
|
template?: MailTemplate | null;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailTemplateEditorModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
template,
|
||||||
|
mode,
|
||||||
|
}: MailTemplateEditorModalProps) {
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async (designerData: any) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// MailDesigner가 보내는 데이터 구조에 맞춰서 처리
|
||||||
|
await onSave({
|
||||||
|
name: designerData.name || designerData.templateName || '제목 없음',
|
||||||
|
subject: designerData.subject || '제목 없음',
|
||||||
|
components: designerData.components || [],
|
||||||
|
category: designerData.category,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('템플릿 저장 실패:', error);
|
||||||
|
alert(error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between shadow-lg z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg p-2 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MailDesigner 컴포넌트 */}
|
||||||
|
<div className="h-[calc(100vh-73px)] overflow-hidden">
|
||||||
|
<MailDesigner
|
||||||
|
templateId={template?.id}
|
||||||
|
onSave={handleSave}
|
||||||
|
onPreview={(data) => {
|
||||||
|
// 미리보기 로직은 MailDesigner 내부에서 처리
|
||||||
|
console.log('Preview:', data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Eye, Mail, Code, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MailTemplate, renderTemplateToHtml, extractTemplateVariables } from '@/lib/api/mail';
|
||||||
|
|
||||||
|
interface MailTemplatePreviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
template: MailTemplate | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailTemplatePreviewModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
template,
|
||||||
|
}: MailTemplatePreviewModalProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
if (!isOpen || !template) return null;
|
||||||
|
|
||||||
|
const templateVariables = extractTemplateVariables(template);
|
||||||
|
const renderedHtml = renderTemplateToHtml(template, variables);
|
||||||
|
|
||||||
|
const handleVariableChange = (key: string, value: string) => {
|
||||||
|
setVariables((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-xl shadow-2xl overflow-hidden transition-all ${
|
||||||
|
isFullscreen ? 'w-full h-full' : 'max-w-6xl w-full max-h-[90vh]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Eye className="w-6 h-6 text-white" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{template.name}</h2>
|
||||||
|
<p className="text-sm text-orange-100">{template.subject}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode(viewMode === 'preview' ? 'code' : 'preview')}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg px-3 py-2 transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{viewMode === 'preview' ? (
|
||||||
|
<>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
코드
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
미리보기
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize2 className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="flex h-full overflow-hidden">
|
||||||
|
{/* 왼쪽: 변수 입력 (변수가 있을 때만) */}
|
||||||
|
{templateVariables.length > 0 && (
|
||||||
|
<div className="w-80 bg-gray-50 border-r border-gray-200 p-6 overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-orange-500" />
|
||||||
|
템플릿 변수
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{templateVariables.map((variable) => (
|
||||||
|
<div key={variable}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{variable}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={variables[variable] || ''}
|
||||||
|
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
||||||
|
placeholder={`{${variable}}`}
|
||||||
|
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 className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
💡 변수 값을 입력하면 미리보기에 반영됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 오른쪽: 미리보기 또는 코드 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{viewMode === 'preview' ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
{/* 이메일 헤더 시뮬레이션 */}
|
||||||
|
<div className="bg-gray-100 px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="font-semibold text-gray-600 w-20">제목:</span>
|
||||||
|
<span className="text-gray-900">{template.subject}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="font-semibold text-gray-600 w-20">발신:</span>
|
||||||
|
<span className="text-gray-700">your-email@company.com</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="font-semibold text-gray-600 w-20">수신:</span>
|
||||||
|
<span className="text-gray-700">recipient@example.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이메일 본문 */}
|
||||||
|
<div
|
||||||
|
className="p-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 text-gray-100 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||||
|
<pre className="whitespace-pre-wrap break-words">{renderedHtml}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode);
|
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode);
|
||||||
setScreenCode(newCode);
|
setScreenCode(newCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 코드 생성 실패:", error);
|
// console.error("화면 코드 생성 실패:", error);
|
||||||
toast.error("화면 코드 생성에 실패했습니다.");
|
toast.error("화면 코드 생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -84,7 +84,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
onCopySuccess();
|
onCopySuccess();
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("화면 복사 실패:", error);
|
// console.error("화면 복사 실패:", error);
|
||||||
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
|
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
const generatedCode = await screenApi.generateScreenCode(companyCode);
|
const generatedCode = await screenApi.generateScreenCode(companyCode);
|
||||||
setScreenCode(generatedCode);
|
setScreenCode(generatedCode);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("화면 코드 생성 실패", e);
|
// console.error("화면 코드 생성 실패", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
if (abort) return;
|
if (abort) return;
|
||||||
setTables(list.map((t) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
setTables(list.map((t) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("테이블 목록 조회 실패", e);
|
// console.error("테이블 목록 조회 실패", e);
|
||||||
setTables([]);
|
setTables([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -93,7 +93,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
setTableName("");
|
setTableName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("화면 생성 실패", e);
|
// console.error("화면 생성 실패", e);
|
||||||
// 필요 시 토스트 추가 가능
|
// 필요 시 토스트 추가 가능
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
||||||
|
|
||||||
return applyStyles(dynamicElement);
|
return applyStyles(dynamicElement);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`DynamicWebTypeRenderer 오류 (${widgetType}):`, error);
|
// console.warn(`DynamicWebTypeRenderer 오류 (${widgetType}):`, error);
|
||||||
|
|
||||||
// 폴백: 기본 input
|
// 폴백: 기본 input
|
||||||
const fallbackElement = (
|
const fallbackElement = (
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
|
||||||
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 업로드 오류:', error);
|
// console.error('파일 업로드 오류:', error);
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.error('파일 업로드에 실패했습니다.');
|
toast.error('파일 업로드에 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -243,7 +243,7 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
|
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 다운로드 오류:', error);
|
// console.error('파일 다운로드 오류:', error);
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.error('파일 다운로드에 실패했습니다.');
|
toast.error('파일 다운로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +275,7 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.success(`${file.realFileName}이 삭제되었습니다.`);
|
toast.success(`${file.realFileName}이 삭제되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 삭제 오류:', error);
|
// console.error('파일 삭제 오류:', error);
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.error('파일 삭제에 실패했습니다.');
|
toast.error('파일 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,13 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
|
|
||||||
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
|
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
|
||||||
|
|
||||||
console.log(`🔧 패널 높이 자동 조정:`, {
|
// console.log(`🔧 패널 높이 자동 조정:`, {
|
||||||
panelId: id,
|
// panelId: id,
|
||||||
contentHeight,
|
// contentHeight,
|
||||||
calculatedHeight: newHeight,
|
// calculatedHeight: newHeight,
|
||||||
currentHeight: panelSize.height,
|
// currentHeight: panelSize.height,
|
||||||
willUpdate: Math.abs(panelSize.height - newHeight) > 10,
|
// willUpdate: Math.abs(panelSize.height - newHeight) > 10,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 현재 높이와 다르면 업데이트
|
// 현재 높이와 다르면 업데이트
|
||||||
if (Math.abs(panelSize.height - newHeight) > 10) {
|
if (Math.abs(panelSize.height - newHeight) > 10) {
|
||||||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
"fixed z-[100] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||||
isResizing && "cursor-se-resize",
|
isResizing && "cursor-se-resize",
|
||||||
className,
|
className,
|
||||||
|
|
@ -239,7 +239,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
height: `${panelSize.height}px`,
|
height: `${panelSize.height}px`,
|
||||||
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
||||||
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
||||||
zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시
|
zIndex: isDragging ? 101 : 100, // 항상 컴포넌트보다 위에 표시
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`공통코드 옵션 로드 실패: ${categoryCode}`, error);
|
// console.error(`공통코드 옵션 로드 실패: ${categoryCode}`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -176,7 +176,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
return { hasFiles, fileCount, files: response.files || [] };
|
return { hasFiles, fileCount, files: response.files || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 상태 확인 오류:", error);
|
// console.error("파일 상태 확인 오류:", error);
|
||||||
return { hasFiles: false, fileCount: 0, files: [] };
|
return { hasFiles: false, fileCount: 0, files: [] };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -235,7 +235,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
return { hasFiles, fileCount, files, targetObjid };
|
return { hasFiles, fileCount, files, targetObjid };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼별 파일 상태 확인 오류:", error);
|
// console.error("컬럼별 파일 상태 확인 오류:", error);
|
||||||
return { hasFiles: false, fileCount: 0, files: [], targetObjid: null };
|
return { hasFiles: false, fileCount: 0, files: [], targetObjid: null };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -301,13 +301,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 이미지 로딩 실패 시 대체 URL 시도
|
// 이미지 로딩 실패 시 대체 URL 시도
|
||||||
const handleImageError = useCallback(() => {
|
const handleImageError = useCallback(() => {
|
||||||
if (!imageLoadError && previewImage) {
|
if (!imageLoadError && previewImage) {
|
||||||
console.error("이미지 로딩 실패:", previewImage);
|
// console.error("이미지 로딩 실패:", previewImage);
|
||||||
setImageLoadError(true);
|
setImageLoadError(true);
|
||||||
|
|
||||||
// 대체 URL 생성 (직접 파일 경로 사용)
|
// 대체 URL 생성 (직접 파일 경로 사용)
|
||||||
if (previewImage.path) {
|
if (previewImage.path) {
|
||||||
const altUrl = getDirectFileUrl(previewImage.path);
|
const altUrl = getDirectFileUrl(previewImage.path);
|
||||||
console.log("대체 URL 시도:", altUrl);
|
// console.log("대체 URL 시도:", altUrl);
|
||||||
setAlternativeImageUrl(altUrl);
|
setAlternativeImageUrl(altUrl);
|
||||||
} else {
|
} else {
|
||||||
toast.error("이미지를 불러올 수 없습니다.");
|
toast.error("이미지를 불러올 수 없습니다.");
|
||||||
|
|
@ -365,7 +365,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
try {
|
try {
|
||||||
return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {};
|
return tableColumn?.detailSettings ? JSON.parse(tableColumn.detailSettings) : {};
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("상세 설정 파싱 실패:", tableColumn?.detailSettings);
|
// console.warn("상세 설정 파싱 실패:", tableColumn?.detailSettings);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -483,7 +483,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setFileStatusMap(statusMap);
|
setFileStatusMap(statusMap);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
// console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||||
setData([]);
|
setData([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
setTotalPages(1);
|
setTotalPages(1);
|
||||||
|
|
@ -503,7 +503,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setCurrentUser(response.data);
|
setCurrentUser(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("현재 사용자 정보 로드 실패:", error);
|
// console.error("현재 사용자 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -515,14 +515,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const handleRefreshFileStatus = async (event: CustomEvent) => {
|
const handleRefreshFileStatus = async (event: CustomEvent) => {
|
||||||
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
|
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
|
||||||
|
|
||||||
console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", {
|
// console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", {
|
||||||
tableName,
|
// tableName,
|
||||||
recordId,
|
// recordId,
|
||||||
columnName,
|
// columnName,
|
||||||
targetObjid,
|
// targetObjid,
|
||||||
fileCount,
|
// fileCount,
|
||||||
currentTableName: component.tableName
|
// currentTableName: component.tableName
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 현재 테이블과 일치하는지 확인
|
// 현재 테이블과 일치하는지 확인
|
||||||
if (tableName === component.tableName) {
|
if (tableName === component.tableName) {
|
||||||
|
|
@ -534,12 +534,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
[columnKey]: { hasFiles: fileCount > 0, fileCount }
|
[columnKey]: { hasFiles: fileCount > 0, fileCount }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("✅ 파일 상태 업데이트 완료:", {
|
// console.log("✅ 파일 상태 업데이트 완료:", {
|
||||||
recordId,
|
// recordId,
|
||||||
columnKey,
|
// columnKey,
|
||||||
hasFiles: fileCount > 0,
|
// hasFiles: fileCount > 0,
|
||||||
fileCount
|
// fileCount
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -559,7 +559,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||||
setTableColumns(columns);
|
setTableColumns(columns);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 컬럼 정보 로드 실패:", error);
|
// console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -820,7 +820,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
handleAddFormChange(columnName, fileNames);
|
handleAddFormChange(columnName, fileNames);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 업로드 실패:", error);
|
// console.error("파일 업로드 실패:", error);
|
||||||
alert("파일 업로드에 실패했습니다.");
|
alert("파일 업로드에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFiles((prev) => ({ ...prev, [columnName]: false }));
|
setUploadingFiles((prev) => ({ ...prev, [columnName]: false }));
|
||||||
|
|
@ -898,7 +898,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
|
|
||||||
// 실제 API 호출로 데이터 추가
|
// 실제 API 호출로 데이터 추가
|
||||||
console.log("🔥 추가할 데이터:", addFormData);
|
// console.log("🔥 추가할 데이터:", addFormData);
|
||||||
await tableTypeApi.addTableData(component.tableName, addFormData);
|
await tableTypeApi.addTableData(component.tableName, addFormData);
|
||||||
|
|
||||||
// 모달 닫기 및 폼 초기화
|
// 모달 닫기 및 폼 초기화
|
||||||
|
|
@ -908,7 +908,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 첫 페이지로 이동하여 새 데이터 확인
|
// 첫 페이지로 이동하여 새 데이터 확인
|
||||||
loadData(1, searchValues);
|
loadData(1, searchValues);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("데이터 추가 실패:", error);
|
// console.error("데이터 추가 실패:", error);
|
||||||
alert("데이터 추가에 실패했습니다.");
|
alert("데이터 추가에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
|
|
@ -921,8 +921,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
|
|
||||||
// 실제 API 호출로 데이터 수정
|
// 실제 API 호출로 데이터 수정
|
||||||
console.log("🔥 수정할 데이터:", editFormData);
|
// console.log("🔥 수정할 데이터:", editFormData);
|
||||||
console.log("🔥 원본 데이터:", editingRowData);
|
// console.log("🔥 원본 데이터:", editingRowData);
|
||||||
|
|
||||||
if (editingRowData) {
|
if (editingRowData) {
|
||||||
await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData);
|
await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData);
|
||||||
|
|
@ -937,7 +937,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
loadData(currentPage, searchValues);
|
loadData(currentPage, searchValues);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("데이터 수정 실패:", error);
|
// console.error("데이터 수정 실패:", error);
|
||||||
alert("데이터 수정에 실패했습니다.");
|
alert("데이터 수정에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
@ -971,7 +971,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const selectedData = Array.from(selectedRows).map((index) => data[index]);
|
const selectedData = Array.from(selectedRows).map((index) => data[index]);
|
||||||
|
|
||||||
// 실제 삭제 API 호출
|
// 실제 삭제 API 호출
|
||||||
console.log("🗑️ 삭제할 데이터:", selectedData);
|
// console.log("🗑️ 삭제할 데이터:", selectedData);
|
||||||
await tableTypeApi.deleteTableData(component.tableName, selectedData);
|
await tableTypeApi.deleteTableData(component.tableName, selectedData);
|
||||||
|
|
||||||
// 선택 해제 및 다이얼로그 닫기
|
// 선택 해제 및 다이얼로그 닫기
|
||||||
|
|
@ -981,7 +981,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 데이터 새로고침
|
// 데이터 새로고침
|
||||||
loadData(currentPage, searchValues);
|
loadData(currentPage, searchValues);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("데이터 삭제 실패:", error);
|
// console.error("데이터 삭제 실패:", error);
|
||||||
alert("데이터 삭제에 실패했습니다.");
|
alert("데이터 삭제에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
|
|
@ -1544,7 +1544,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
// File 객체 유효성 검사
|
// File 객체 유효성 검사
|
||||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||||
console.error("❌ 잘못된 파일 객체:", file);
|
// console.error("❌ 잘못된 파일 객체:", file);
|
||||||
toast.error("파일 객체가 손상되었습니다. 파일을 다시 업로드해주세요.");
|
toast.error("파일 객체가 손상되었습니다. 파일을 다시 업로드해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1560,7 +1560,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 로컬 파일 다운로드 오류:", error);
|
// console.error("❌ 로컬 파일 다운로드 오류:", error);
|
||||||
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
|
toast.error("로컬 파일 다운로드에 실패했습니다. 파일을 다시 업로드해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1580,7 +1580,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 다운로드 오류:", error);
|
// console.error("파일 다운로드 오류:", error);
|
||||||
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
|
toast.error(`${fileInfo.name} 다운로드에 실패했습니다.`);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1589,7 +1589,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const handleDeleteLinkedFile = useCallback(
|
const handleDeleteLinkedFile = useCallback(
|
||||||
async (fileId: string, fileName: string) => {
|
async (fileId: string, fileName: string) => {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 파일 삭제 시작:", { fileId, fileName });
|
// console.log("🗑️ 파일 삭제 시작:", { fileId, fileName });
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그
|
// 삭제 확인 다이얼로그
|
||||||
if (!confirm(`"${fileName}" 파일을 삭제하시겠습니까?`)) {
|
if (!confirm(`"${fileName}" 파일을 삭제하시겠습니까?`)) {
|
||||||
|
|
@ -1605,7 +1605,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
console.log("📡 파일 삭제 API 응답:", result);
|
// console.log("📡 파일 삭제 API 응답:", result);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || "파일 삭제 실패");
|
throw new Error(result.message || "파일 삭제 실패");
|
||||||
|
|
@ -1622,15 +1622,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
try {
|
try {
|
||||||
const response = await getLinkedFiles(component.tableName, recordId);
|
const response = await getLinkedFiles(component.tableName, recordId);
|
||||||
setLinkedFiles(response.files || []);
|
setLinkedFiles(response.files || []);
|
||||||
console.log("📁 파일 목록 새로고침 완료:", response.files?.length || 0);
|
// console.log("📁 파일 목록 새로고침 완료:", response.files?.length || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 목록 새로고침 실패:", error);
|
// console.error("파일 목록 새로고침 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 파일 삭제 완료:", fileName);
|
// console.log("✅ 파일 삭제 완료:", fileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 파일 삭제 실패:", error);
|
// console.error("❌ 파일 삭제 실패:", error);
|
||||||
toast.error(`"${fileName}" 파일 삭제에 실패했습니다.`);
|
toast.error(`"${fileName}" 파일 삭제에 실패했습니다.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1852,7 +1852,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : data.length > 0 ? (
|
) : data.length > 0 ? (
|
||||||
data.map((row, rowIndex) => (
|
data.map((row, rowIndex) => (
|
||||||
<TableRow key={rowIndex} className="hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 transition-all duration-200">
|
<TableRow key={rowIndex} className="hover:bg-orange-100 transition-all duration-200">
|
||||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||||
{component.enableDelete && (
|
{component.enableDelete && (
|
||||||
<TableCell className="w-12 px-4">
|
<TableCell className="w-12 px-4">
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const loadPopupLayout = async () => {
|
const loadPopupLayout = async () => {
|
||||||
try {
|
try {
|
||||||
setPopupLoading(true);
|
setPopupLoading(true);
|
||||||
console.log("🔍 팝업 화면 로드 시작:", popupScreen);
|
// console.log("🔍 팝업 화면 로드 시작:", popupScreen);
|
||||||
|
|
||||||
// 화면 레이아웃과 화면 정보를 병렬로 가져오기
|
// 화면 레이아웃과 화면 정보를 병렬로 가져오기
|
||||||
const [layout, screen] = await Promise.all([
|
const [layout, screen] = await Promise.all([
|
||||||
|
|
@ -180,7 +180,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 팝업 formData 초기화
|
// 팝업 formData 초기화
|
||||||
setPopupFormData({});
|
setPopupFormData({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 팝업 화면 로드 실패:", error);
|
// console.error("❌ 팝업 화면 로드 실패:", error);
|
||||||
setPopupLayout([]);
|
setPopupLayout([]);
|
||||||
setPopupScreenInfo(null);
|
setPopupScreenInfo(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -203,27 +203,27 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 폼 데이터 업데이트
|
// 폼 데이터 업데이트
|
||||||
const updateFormData = (fieldName: string, value: any) => {
|
const updateFormData = (fieldName: string, value: any) => {
|
||||||
console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
|
// console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
|
||||||
|
|
||||||
// 항상 로컬 상태도 업데이트
|
// 항상 로컬 상태도 업데이트
|
||||||
setLocalFormData((prev) => ({
|
setLocalFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
}));
|
}));
|
||||||
console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
// console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
||||||
|
|
||||||
// 외부 콜백이 있는 경우에도 전달 (개별 필드 단위로)
|
// 외부 콜백이 있는 경우에도 전달 (개별 필드 단위로)
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange(fieldName, value);
|
onFormDataChange(fieldName, value);
|
||||||
console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}"`);
|
// console.log(`📤 외부 콜백으로 전달: ${fieldName} = "${value}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자동입력 필드들의 값을 formData에 초기 설정
|
// 자동입력 필드들의 값을 formData에 초기 설정
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
// console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length);
|
||||||
const initAutoInputFields = () => {
|
const initAutoInputFields = () => {
|
||||||
console.log("🔧 initAutoInputFields 실행 시작");
|
// console.log("🔧 initAutoInputFields 실행 시작");
|
||||||
allComponents.forEach(comp => {
|
allComponents.forEach(comp => {
|
||||||
if (comp.type === 'widget') {
|
if (comp.type === 'widget') {
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
|
|
@ -258,7 +258,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
updateFormData(fieldName, autoValue);
|
updateFormData(fieldName, autoValue);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`);
|
// console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -378,7 +378,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 입력 검증 함수
|
// 입력 검증 함수
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
console.log(`📝 입력 변경: ${fieldName} = "${value}"`);
|
// console.log(`📝 입력 변경: ${fieldName} = "${value}"`);
|
||||||
|
|
||||||
// 형식별 실시간 검증
|
// 형식별 실시간 검증
|
||||||
if (config?.format && config.format !== "none") {
|
if (config?.format && config.format !== "none") {
|
||||||
|
|
@ -386,7 +386,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
const regex = new RegExp(`^${pattern}$`);
|
const regex = new RegExp(`^${pattern}$`);
|
||||||
if (value && !regex.test(value)) {
|
if (value && !regex.test(value)) {
|
||||||
console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`);
|
// console.log(`❌ 형식 검증 실패: ${fieldName} = "${value}"`);
|
||||||
return; // 유효하지 않은 입력 차단
|
return; // 유효하지 않은 입력 차단
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -394,11 +394,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 길이 제한 검증
|
// 길이 제한 검증
|
||||||
if (config?.maxLength && value.length > config.maxLength) {
|
if (config?.maxLength && value.length > config.maxLength) {
|
||||||
console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`);
|
// console.log(`❌ 길이 제한 초과: ${fieldName} = "${value}" (최대: ${config.maxLength})`);
|
||||||
return; // 최대 길이 초과 차단
|
return; // 최대 길이 초과 차단
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`);
|
// console.log(`✅ updateFormData 호출: ${fieldName} = "${value}"`);
|
||||||
updateFormData(fieldName, value);
|
updateFormData(fieldName, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -797,16 +797,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 파일 선택을 취소한 경우 (files가 null이거나 길이가 0)
|
// 파일 선택을 취소한 경우 (files가 null이거나 길이가 0)
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
console.log("📁 파일 선택 취소됨 - 기존 파일 유지");
|
// console.log("📁 파일 선택 취소됨 - 기존 파일 유지");
|
||||||
|
|
||||||
// 현재 저장된 파일이 있는지 확인
|
// 현재 저장된 파일이 있는지 확인
|
||||||
const currentStoredValue = externalFormData?.[fieldName] || localFormData[fieldName];
|
const currentStoredValue = externalFormData?.[fieldName] || localFormData[fieldName];
|
||||||
if (currentStoredValue) {
|
if (currentStoredValue) {
|
||||||
console.log("📁 기존 파일 있음 - 유지:", currentStoredValue);
|
// console.log("📁 기존 파일 있음 - 유지:", currentStoredValue);
|
||||||
// 기존 파일이 있으면 그대로 유지 (아무것도 하지 않음)
|
// 기존 파일이 있으면 그대로 유지 (아무것도 하지 않음)
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.log("📁 기존 파일 없음 - 빈 상태 유지");
|
// console.log("📁 기존 파일 없음 - 빈 상태 유지");
|
||||||
// 기존 파일이 없으면 빈 상태 유지
|
// 기존 파일이 없으면 빈 상태 유지
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -831,7 +831,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const uploadResult = await uploadFilesAndCreateData(files);
|
const uploadResult = await uploadFilesAndCreateData(files);
|
||||||
|
|
||||||
if (uploadResult.success) {
|
if (uploadResult.success) {
|
||||||
console.log("📁 업로드 완료된 파일 데이터:", uploadResult.data);
|
// console.log("📁 업로드 완료된 파일 데이터:", uploadResult.data);
|
||||||
|
|
||||||
setLocalFormData(prev => ({ ...prev, [fieldName]: uploadResult.data }));
|
setLocalFormData(prev => ({ ...prev, [fieldName]: uploadResult.data }));
|
||||||
|
|
||||||
|
|
@ -845,7 +845,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
throw new Error("파일 업로드에 실패했습니다.");
|
throw new Error("파일 업로드에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 업로드 오류:", error);
|
// console.error("파일 업로드 오류:", error);
|
||||||
toast.error("파일 업로드에 실패했습니다.");
|
toast.error("파일 업로드에 실패했습니다.");
|
||||||
|
|
||||||
// 파일 입력 초기화
|
// 파일 입력 초기화
|
||||||
|
|
@ -1023,12 +1023,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
isCodeType: true, // 코드 타입임을 명시
|
isCodeType: true, // 코드 타입임을 명시
|
||||||
}}
|
}}
|
||||||
onEvent={(event: string, data: any) => {
|
onEvent={(event: string, data: any) => {
|
||||||
console.log(`Code widget event: ${event}`, data);
|
// console.log(`Code widget event: ${event}`, data);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DynamicWebTypeRenderer 로딩 실패, 기본 Select 사용:", error);
|
// console.error("DynamicWebTypeRenderer 로딩 실패, 기본 Select 사용:", error);
|
||||||
|
|
||||||
// 폴백: 기본 Select 컴포넌트 사용
|
// 폴백: 기본 Select 컴포넌트 사용
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
|
|
@ -1148,21 +1148,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
await handleCustomAction();
|
await handleCustomAction();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log(`알 수 없는 액션 타입: ${actionType}`);
|
// console.log(`알 수 없는 액션 타입: ${actionType}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`버튼 액션 실행 오류 (${actionType}):`, error);
|
// console.error(`버튼 액션 실행 오류 (${actionType}):`, error);
|
||||||
alert(`작업 중 오류가 발생했습니다: ${error.message}`);
|
alert(`작업 중 오류가 발생했습니다: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 액션 (개선된 버전)
|
// 저장 액션 (개선된 버전)
|
||||||
const handleSaveAction = async () => {
|
const handleSaveAction = async () => {
|
||||||
console.log("💾 저장 시작");
|
// console.log("💾 저장 시작");
|
||||||
|
|
||||||
// 개선된 검증 시스템이 활성화된 경우
|
// 개선된 검증 시스템이 활성화된 경우
|
||||||
if (enhancedValidation) {
|
if (enhancedValidation) {
|
||||||
console.log("🔍 개선된 검증 시스템 사용");
|
// console.log("🔍 개선된 검증 시스템 사용");
|
||||||
const success = await enhancedValidation.saveForm();
|
const success = await enhancedValidation.saveForm();
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||||
|
|
@ -1172,7 +1172,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 기존 방식 (레거시 지원)
|
// 기존 방식 (레거시 지원)
|
||||||
const currentFormData = { ...localFormData, ...externalFormData };
|
const currentFormData = { ...localFormData, ...externalFormData };
|
||||||
console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData);
|
// console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData);
|
||||||
|
|
||||||
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
||||||
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
|
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
|
||||||
|
|
@ -1249,7 +1249,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
existingValue: value
|
existingValue: value
|
||||||
});
|
});
|
||||||
} else if (!isAutoInput) {
|
} else if (!isAutoInput) {
|
||||||
console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`);
|
// console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1260,7 +1260,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
mappedData[saveKey] = value;
|
mappedData[saveKey] = value;
|
||||||
} else if (widget.columnName) {
|
} else if (widget.columnName) {
|
||||||
// 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장
|
// 값이 없지만 columnName이 있는 경우, 빈 문자열로 저장
|
||||||
console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`);
|
// console.log(`⚠️ ${widget.columnName} 필드에 값이 없어 빈 문자열로 저장`);
|
||||||
mappedData[widget.columnName] = "";
|
mappedData[widget.columnName] = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1275,20 +1275,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
});
|
});
|
||||||
|
|
||||||
// 각 컴포넌트의 상세 정보 로그
|
// 각 컴포넌트의 상세 정보 로그
|
||||||
console.log("🔍 컴포넌트별 데이터 수집 상세:");
|
// console.log("🔍 컴포넌트별 데이터 수집 상세:");
|
||||||
allComponents.forEach(comp => {
|
allComponents.forEach(comp => {
|
||||||
if (comp.type === 'widget') {
|
if (comp.type === 'widget') {
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const fieldName = widget.columnName || widget.id;
|
const fieldName = widget.columnName || widget.id;
|
||||||
const value = currentFormData[fieldName];
|
const value = currentFormData[fieldName];
|
||||||
const hasValue = value !== undefined && value !== null && value !== '';
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`);
|
// console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 매핑된 데이터가 비어있으면 경고
|
// 매핑된 데이터가 비어있으면 경고
|
||||||
if (Object.keys(mappedData).length === 0) {
|
if (Object.keys(mappedData).length === 0) {
|
||||||
console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다.");
|
// console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
|
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
|
||||||
|
|
@ -1302,13 +1302,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
data: mappedData,
|
data: mappedData,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀 API 저장 요청:", saveData);
|
// console.log("🚀 API 저장 요청:", saveData);
|
||||||
|
|
||||||
const result = await dynamicFormApi.saveFormData(saveData);
|
const result = await dynamicFormApi.saveFormData(saveData);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("저장되었습니다.");
|
alert("저장되었습니다.");
|
||||||
console.log("✅ 저장 성공:", result.data);
|
// console.log("✅ 저장 성공:", result.data);
|
||||||
|
|
||||||
// 저장 후 데이터 초기화 (선택사항)
|
// 저장 후 데이터 초기화 (선택사항)
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
|
|
@ -1322,7 +1322,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
throw new Error(result.message || "저장에 실패했습니다.");
|
throw new Error(result.message || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 저장 실패:", error);
|
// console.error("❌ 저장 실패:", error);
|
||||||
alert(`저장 중 오류가 발생했습니다: ${error.message}`);
|
alert(`저장 중 오류가 발생했습니다: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1355,13 +1355,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
|
||||||
|
|
||||||
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert("삭제되었습니다.");
|
alert("삭제되었습니다.");
|
||||||
console.log("✅ 삭제 성공");
|
// console.log("✅ 삭제 성공");
|
||||||
|
|
||||||
// 삭제 후 폼 초기화
|
// 삭제 후 폼 초기화
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
|
|
@ -1375,28 +1375,28 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
throw new Error(result.message || "삭제에 실패했습니다.");
|
throw new Error(result.message || "삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 삭제 실패:", error);
|
// console.error("❌ 삭제 실패:", error);
|
||||||
alert(`삭제 중 오류가 발생했습니다: ${error.message}`);
|
alert(`삭제 중 오류가 발생했습니다: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 편집 액션
|
// 편집 액션
|
||||||
const handleEditAction = () => {
|
const handleEditAction = () => {
|
||||||
console.log("✏️ 편집 모드 활성화");
|
// console.log("✏️ 편집 모드 활성화");
|
||||||
// 읽기 전용 모드를 편집 모드로 전환
|
// 읽기 전용 모드를 편집 모드로 전환
|
||||||
alert("편집 모드로 전환되었습니다.");
|
alert("편집 모드로 전환되었습니다.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 추가 액션
|
// 추가 액션
|
||||||
const handleAddAction = () => {
|
const handleAddAction = () => {
|
||||||
console.log("➕ 새 항목 추가");
|
// console.log("➕ 새 항목 추가");
|
||||||
// 새 항목 추가 로직
|
// 새 항목 추가 로직
|
||||||
alert("새 항목을 추가할 수 있습니다.");
|
alert("새 항목을 추가할 수 있습니다.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 검색 액션
|
// 검색 액션
|
||||||
const handleSearchAction = () => {
|
const handleSearchAction = () => {
|
||||||
console.log("🔍 검색 실행:", formData);
|
// console.log("🔍 검색 실행:", formData);
|
||||||
// 검색 로직
|
// 검색 로직
|
||||||
const searchTerms = Object.values(formData).filter(v => v && v.toString().trim());
|
const searchTerms = Object.values(formData).filter(v => v && v.toString().trim());
|
||||||
if (searchTerms.length === 0) {
|
if (searchTerms.length === 0) {
|
||||||
|
|
@ -1416,21 +1416,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
});
|
});
|
||||||
onFormDataChange(resetData);
|
onFormDataChange(resetData);
|
||||||
}
|
}
|
||||||
console.log("🔄 폼 초기화 완료");
|
// console.log("🔄 폼 초기화 완료");
|
||||||
alert("입력이 초기화되었습니다.");
|
alert("입력이 초기화되었습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 제출 액션
|
// 제출 액션
|
||||||
const handleSubmitAction = async () => {
|
const handleSubmitAction = async () => {
|
||||||
console.log("📤 폼 제출:", formData);
|
// console.log("📤 폼 제출:", formData);
|
||||||
// 제출 로직
|
// 제출 로직
|
||||||
alert("제출되었습니다.");
|
alert("제출되었습니다.");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 닫기 액션
|
// 닫기 액션
|
||||||
const handleCloseAction = () => {
|
const handleCloseAction = () => {
|
||||||
console.log("❌ 닫기 액션 실행");
|
// console.log("❌ 닫기 액션 실행");
|
||||||
|
|
||||||
// 모달 내부에서 실행되는지 확인
|
// 모달 내부에서 실행되는지 확인
|
||||||
const isInModal = document.querySelector('[role="dialog"]') !== null;
|
const isInModal = document.querySelector('[role="dialog"]') !== null;
|
||||||
|
|
@ -1438,7 +1438,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
if (isInModal) {
|
if (isInModal) {
|
||||||
// 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생
|
// 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생
|
||||||
console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도");
|
// console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도");
|
||||||
|
|
||||||
// 모달의 닫기 버튼을 찾아서 클릭
|
// 모달의 닫기 버튼을 찾아서 클릭
|
||||||
const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close');
|
const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close');
|
||||||
|
|
@ -1451,18 +1451,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
} else if (isInPopup) {
|
} else if (isInPopup) {
|
||||||
// 팝업 창인 경우
|
// 팝업 창인 경우
|
||||||
console.log("🔄 팝업 창 닫기");
|
// console.log("🔄 팝업 창 닫기");
|
||||||
window.close();
|
window.close();
|
||||||
} else {
|
} else {
|
||||||
// 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음
|
// 일반 페이지인 경우 - 이전 페이지로 이동하지 않고 아무것도 하지 않음
|
||||||
console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음");
|
// console.log("🔄 일반 페이지에서 닫기 - 아무 동작 하지 않음");
|
||||||
alert("닫기 버튼이 클릭되었습니다.");
|
alert("닫기 버튼이 클릭되었습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 팝업 액션
|
// 팝업 액션
|
||||||
const handlePopupAction = () => {
|
const handlePopupAction = () => {
|
||||||
console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId });
|
// console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId });
|
||||||
|
|
||||||
if (config?.popupScreenId) {
|
if (config?.popupScreenId) {
|
||||||
// 화면 모달 열기
|
// 화면 모달 열기
|
||||||
|
|
@ -1528,12 +1528,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
await result;
|
await result;
|
||||||
}
|
}
|
||||||
console.log("⚡ 커스텀 액션 실행 완료");
|
// console.log("⚡ 커스텀 액션 실행 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("⚡ 커스텀 액션이 설정되지 않았습니다.");
|
// console.log("⚡ 커스텀 액션이 설정되지 않았습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1619,7 +1619,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
company_code: file.companyCode
|
company_code: file.companyCode
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB);
|
// console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB);
|
||||||
|
|
||||||
// FormData에는 파일 연결 정보만 저장 (간단한 형태)
|
// FormData에는 파일 연결 정보만 저장 (간단한 형태)
|
||||||
const formDataValue = {
|
const formDataValue = {
|
||||||
|
|
@ -1633,7 +1633,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📝 FormData 저장값:", { fieldName, formDataValue });
|
// console.log("📝 FormData 저장값:", { fieldName, formDataValue });
|
||||||
onFormDataChange(fieldName, formDataValue);
|
onFormDataChange(fieldName, formDataValue);
|
||||||
|
|
||||||
// TODO: 실제 API 연동 시 attach_file_info 테이블에 저장
|
// TODO: 실제 API 연동 시 attach_file_info 테이블에 저장
|
||||||
|
|
@ -1696,7 +1696,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 라벨 표시 여부 계산
|
// 라벨 표시 여부 계산
|
||||||
const shouldShowLabel =
|
const shouldShowLabel =
|
||||||
!hideLabel && // hideLabel이 true면 라벨 숨김
|
!hideLabel && // hideLabel이 true면 라벨 숨김
|
||||||
component.style?.labelDisplay !== false &&
|
(component.style?.labelDisplay ?? true) &&
|
||||||
(component.label || component.style?.labelText) &&
|
(component.label || component.style?.labelText) &&
|
||||||
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
setPopupScreen(null);
|
setPopupScreen(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("팝업 화면 로드 오류:", error);
|
// console.error("팝업 화면 로드 오류:", error);
|
||||||
toast.error("팝업 화면 로드 중 오류가 발생했습니다.");
|
toast.error("팝업 화면 로드 중 오류가 발생했습니다.");
|
||||||
setPopupScreen(null);
|
setPopupScreen(null);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -129,14 +129,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
|
|
||||||
// 폼 데이터 변경 핸들러
|
// 폼 데이터 변경 핸들러
|
||||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||||
console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`);
|
// console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||||
console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange);
|
// console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange);
|
||||||
|
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`);
|
// console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||||
onFormDataChange(fieldName, value);
|
onFormDataChange(fieldName, value);
|
||||||
} else {
|
} else {
|
||||||
console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
// console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
||||||
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -188,11 +188,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
tableName={screenInfo?.tableName}
|
tableName={screenInfo?.tableName}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
// 화면 새로고침 로직 (필요시 구현)
|
// 화면 새로고침 로직 (필요시 구현)
|
||||||
console.log("화면 새로고침 요청");
|
// console.log("화면 새로고침 요청");
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// 화면 닫기 로직 (필요시 구현)
|
// 화면 닫기 로직 (필요시 구현)
|
||||||
console.log("화면 닫기 요청");
|
// console.log("화면 닫기 요청");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -240,14 +240,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
config={widget.webTypeConfig}
|
config={widget.webTypeConfig}
|
||||||
onEvent={(event: string, data: any) => {
|
onEvent={(event: string, data: any) => {
|
||||||
// 이벤트 처리
|
// 이벤트 처리
|
||||||
console.log(`Widget event: ${event}`, data);
|
// console.log(`Widget event: ${event}`, data);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return applyStyles(dynamicElement);
|
return applyStyles(dynamicElement);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`웹타입 "${widgetType}" 대화형 렌더링 실패:`, error);
|
// console.error(`웹타입 "${widgetType}" 대화형 렌더링 실패:`, error);
|
||||||
// 오류 발생 시 폴백으로 기본 input 렌더링
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||||
const fallbackElement = (
|
const fallbackElement = (
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -297,7 +297,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
data: formData,
|
data: formData,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("💾 저장 액션 실행:", saveData);
|
// console.log("💾 저장 액션 실행:", saveData);
|
||||||
const response = await dynamicFormApi.saveData(saveData);
|
const response = await dynamicFormApi.saveData(saveData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -306,14 +306,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
toast.error(response.message || "저장에 실패했습니다.");
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 오류:", error);
|
// console.error("저장 오류:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAction = async () => {
|
const handleDeleteAction = async () => {
|
||||||
if (!config?.confirmationEnabled || window.confirm(config.confirmationMessage || "정말 삭제하시겠습니까?")) {
|
if (!config?.confirmationEnabled || window.confirm(config.confirmationMessage || "정말 삭제하시겠습니까?")) {
|
||||||
console.log("🗑️ 삭제 액션 실행");
|
// console.log("🗑️ 삭제 액션 실행");
|
||||||
toast.success("삭제가 완료되었습니다.");
|
toast.success("삭제가 완료되었습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -355,7 +355,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
await result;
|
await result;
|
||||||
}
|
}
|
||||||
console.log("⚡ 커스텀 액션 실행 완료");
|
// console.log("⚡ 커스텀 액션 실행 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
@ -383,10 +383,10 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
await handleCustomAction();
|
await handleCustomAction();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("🔘 기본 버튼 클릭");
|
// console.log("🔘 기본 버튼 클릭");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("버튼 액션 오류:", error);
|
// console.error("버튼 액션 오류:", error);
|
||||||
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
|
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -451,7 +451,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
...formData
|
...formData
|
||||||
}}
|
}}
|
||||||
onFormDataChange={(data) => {
|
onFormDataChange={(data) => {
|
||||||
console.log("📝 실제 화면 파일 업로드 완료:", data);
|
// console.log("📝 실제 화면 파일 업로드 완료:", data);
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
onFormDataChange(key, value);
|
onFormDataChange(key, value);
|
||||||
|
|
@ -486,25 +486,25 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
// console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
||||||
|
|
||||||
const event = new CustomEvent('globalFileStateChanged', {
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: eventDetail
|
detail: eventDetail
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||||
|
|
||||||
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true }
|
detail: { ...eventDetail, delayed: true }
|
||||||
}));
|
}));
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
}));
|
}));
|
||||||
|
|
@ -532,12 +532,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute" style={componentStyle}>
|
<div className="absolute" style={componentStyle}>
|
||||||
<div className="h-full w-full">
|
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
{/* 위젯 렌더링 */}
|
||||||
|
{renderInteractiveWidget(component)}
|
||||||
{/* 위젯 렌더링 */}
|
|
||||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 팝업 화면 렌더링 */}
|
{/* 팝업 화면 렌더링 */}
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,13 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
company_name: menu.company_name || menu.COMPANY_NAME,
|
company_name: menu.company_name || menu.COMPANY_NAME,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("로드된 관리자 메뉴 목록:", {
|
// console.log("로드된 관리자 메뉴 목록:", {
|
||||||
total: normalizedAdminMenus.length,
|
// total: normalizedAdminMenus.length,
|
||||||
sample: normalizedAdminMenus.slice(0, 3),
|
// sample: normalizedAdminMenus.slice(0, 3),
|
||||||
});
|
// });
|
||||||
setMenus(normalizedAdminMenus);
|
setMenus(normalizedAdminMenus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메뉴 목록 로드 실패:", error);
|
// console.error("메뉴 목록 로드 실패:", error);
|
||||||
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -117,10 +117,10 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
if (menuObjid > 0) {
|
if (menuObjid > 0) {
|
||||||
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
setExistingScreens(screens);
|
setExistingScreens(screens);
|
||||||
console.log(`메뉴 "${menu.menu_name_kor}"에 할당된 화면:`, screens);
|
// console.log(`메뉴 "${menu.menu_name_kor}"에 할당된 화면:`, screens);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("할당된 화면 조회 실패:", error);
|
// console.error("할당된 화면 조회 실패:", error);
|
||||||
setExistingScreens([]);
|
setExistingScreens([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +166,13 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
|
|
||||||
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
||||||
if (replaceExisting && existingScreens.length > 0) {
|
if (replaceExisting && existingScreens.length > 0) {
|
||||||
console.log("기존 화면들 제거 중...", existingScreens);
|
// console.log("기존 화면들 제거 중...", existingScreens);
|
||||||
for (const existingScreen of existingScreens) {
|
for (const existingScreen of existingScreens) {
|
||||||
try {
|
try {
|
||||||
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
|
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
|
||||||
console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
// console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`기존 화면 "${existingScreen.screenName}" 제거 실패:`, error);
|
// console.error(`기존 화면 "${existingScreen.screenName}" 제거 실패:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +202,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("화면 할당 실패:", error);
|
// console.error("화면 할당 실패:", error);
|
||||||
const errorMessage = error.response?.data?.message || "화면 할당에 실패했습니다.";
|
const errorMessage = error.response?.data?.message || "화면 할당에 실패했습니다.";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`🔘 Button clicked: ${component.id} (${config?.actionType})`);
|
// console.log(`🔘 Button clicked: ${component.id} (${config?.actionType})`);
|
||||||
|
|
||||||
// 🔥 확장된 컨텍스트 데이터 수집
|
// 🔥 확장된 컨텍스트 데이터 수집
|
||||||
const contextData = {
|
const contextData = {
|
||||||
|
|
@ -88,14 +88,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
// 🔥 제어 전용 액션인지 확인
|
// 🔥 제어 전용 액션인지 확인
|
||||||
const isControlOnlyAction = config?.actionType === "control";
|
const isControlOnlyAction = config?.actionType === "control";
|
||||||
|
|
||||||
console.log("🎯 OptimizedButtonComponent 실행:", {
|
// console.log("🎯 OptimizedButtonComponent 실행:", {
|
||||||
actionType: config?.actionType,
|
// actionType: config?.actionType,
|
||||||
isControlOnlyAction,
|
// isControlOnlyAction,
|
||||||
enableDataflowControl: config?.enableDataflowControl,
|
// enableDataflowControl: config?.enableDataflowControl,
|
||||||
hasDataflowConfig: !!config?.dataflowConfig,
|
// hasDataflowConfig: !!config?.dataflowConfig,
|
||||||
selectedRows,
|
// selectedRows,
|
||||||
selectedRowsData,
|
// selectedRowsData,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
||||||
// 🔥 확장된 제어 검증 먼저 실행
|
// 🔥 확장된 제어 검증 먼저 실행
|
||||||
|
|
@ -131,7 +131,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
await executeOriginalAction(config?.actionType || "save", contextData);
|
await executeOriginalAction(config?.actionType || "save", contextData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Button execution failed:", error);
|
// console.error("Button execution failed:", error);
|
||||||
toast.error("버튼 실행 중 오류가 발생했습니다.");
|
toast.error("버튼 실행 중 오류가 발생했습니다.");
|
||||||
setLastResult({ success: false, error: error.message });
|
setLastResult({ success: false, error: error.message });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -142,9 +142,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
|
|
||||||
// 성능 로깅
|
// 성능 로깅
|
||||||
if (totalTime > 200) {
|
if (totalTime > 200) {
|
||||||
console.warn(`🐌 Slow button execution: ${totalTime.toFixed(2)}ms`);
|
// console.warn(`🐌 Slow button execution: ${totalTime.toFixed(2)}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⚡ Button execution: ${totalTime.toFixed(2)}ms`);
|
// console.log(`⚡ Button execution: ${totalTime.toFixed(2)}ms`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isExecuting, disabled, component.id, config?.actionType, config?.enableDataflowControl, formData, clickCount]);
|
}, [isExecuting, disabled, component.id, config?.actionType, config?.enableDataflowControl, formData, clickCount]);
|
||||||
|
|
@ -212,22 +212,22 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
|
|
||||||
switch (actionType) {
|
switch (actionType) {
|
||||||
case "save":
|
case "save":
|
||||||
console.log("💾 Save action completed:", result);
|
// console.log("💾 Save action completed:", result);
|
||||||
break;
|
break;
|
||||||
case "delete":
|
case "delete":
|
||||||
console.log("🗑️ Delete action completed:", result);
|
// console.log("🗑️ Delete action completed:", result);
|
||||||
break;
|
break;
|
||||||
case "search":
|
case "search":
|
||||||
console.log("🔍 Search action completed:", result);
|
// console.log("🔍 Search action completed:", result);
|
||||||
break;
|
break;
|
||||||
case "add":
|
case "add":
|
||||||
console.log("➕ Add action completed:", result);
|
// console.log("➕ Add action completed:", result);
|
||||||
break;
|
break;
|
||||||
case "edit":
|
case "edit":
|
||||||
console.log("✏️ Edit action completed:", result);
|
// console.log("✏️ Edit action completed:", result);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.log(`✅ ${actionType} action completed:`, result);
|
// console.log(`✅ ${actionType} action completed:`, result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -289,7 +289,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.error("Background job failed:", status.result);
|
// console.error("Background job failed:", status.result);
|
||||||
toast.error("백그라운드 처리 중 오류가 발생했습니다.", { duration: 3000 });
|
toast.error("백그라운드 처리 중 오류가 발생했습니다.", { duration: 3000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +298,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
if (pollCount < maxPolls && (status.status === "pending" || status.status === "processing")) {
|
if (pollCount < maxPolls && (status.status === "pending" || status.status === "processing")) {
|
||||||
setTimeout(pollJobStatus, pollInterval);
|
setTimeout(pollJobStatus, pollInterval);
|
||||||
} else if (pollCount >= maxPolls) {
|
} else if (pollCount >= maxPolls) {
|
||||||
console.warn(`Background job polling timeout: ${jobId}`);
|
// console.warn(`Background job polling timeout: ${jobId}`);
|
||||||
setBackgroundJobs((prev) => {
|
setBackgroundJobs((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(jobId);
|
newSet.delete(jobId);
|
||||||
|
|
@ -306,7 +306,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to check job status:", error);
|
// console.error("Failed to check job status:", error);
|
||||||
setBackgroundJobs((prev) => {
|
setBackgroundJobs((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(jobId);
|
newSet.delete(jobId);
|
||||||
|
|
@ -388,7 +388,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
// 🔥 제어 액션은 여기서 처리하지 않음 (이미 위에서 처리됨)
|
// 🔥 제어 액션은 여기서 처리하지 않음 (이미 위에서 처리됨)
|
||||||
if (actionType === "control") {
|
if (actionType === "control") {
|
||||||
console.warn("제어 액션은 executeOriginalAction에서 처리되지 않아야 합니다.");
|
// console.warn("제어 액션은 executeOriginalAction에서 처리되지 않아야 합니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
||||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||||
|
|
||||||
// 디버깅: 실제 widgetType 값 확인
|
// 디버깅: 실제 widgetType 값 확인
|
||||||
console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
// console.log("RealtimePreviewDynamic - widgetType:", widgetType, "columnName:", columnName);
|
||||||
|
|
||||||
// 사용자가 테두리를 설정했는지 확인
|
// 사용자가 테두리를 설정했는지 확인
|
||||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||||
|
|
@ -129,11 +129,11 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
||||||
|
|
||||||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||||
if (isFileComponent(widget)) {
|
if (isFileComponent(widget)) {
|
||||||
console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||||
componentId: widget.id,
|
// componentId: widget.id,
|
||||||
widgetType: widgetType,
|
// widgetType: widgetType,
|
||||||
isFileComponent: true
|
// isFileComponent: true
|
||||||
});
|
// });
|
||||||
|
|
||||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,7 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
// console.error(`웹타입 "${widgetType}" 렌더링 실패:`, error);
|
||||||
// 오류 발생 시 폴백으로 기본 input 렌더링
|
// 오류 발생 시 폴백으로 기본 input 렌더링
|
||||||
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
|
return <Input type="text" {...commonProps} placeholder={`${widgetType} (렌더링 오류)`} />;
|
||||||
}
|
}
|
||||||
|
|
@ -226,66 +226,66 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||||
eventComponentId: event.detail.componentId,
|
// eventComponentId: event.detail.componentId,
|
||||||
currentComponentId: component.id,
|
// currentComponentId: component.id,
|
||||||
isMatch: event.detail.componentId === component.id,
|
// isMatch: event.detail.componentId === component.id,
|
||||||
filesCount: event.detail.files?.length || 0,
|
// filesCount: event.detail.files?.length || 0,
|
||||||
action: event.detail.action,
|
// action: event.detail.action,
|
||||||
delayed: event.detail.delayed || false,
|
// delayed: event.detail.delayed || false,
|
||||||
attempt: event.detail.attempt || 1,
|
// attempt: event.detail.attempt || 1,
|
||||||
eventDetail: event.detail
|
// eventDetail: event.detail
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (event.detail.componentId === component.id) {
|
if (event.detail.componentId === component.id) {
|
||||||
console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
filesCount: event.detail.files?.length || 0,
|
// filesCount: event.detail.files?.length || 0,
|
||||||
action: event.detail.action,
|
// action: event.detail.action,
|
||||||
oldTrigger: fileUpdateTrigger,
|
// oldTrigger: fileUpdateTrigger,
|
||||||
delayed: event.detail.delayed || false,
|
// delayed: event.detail.delayed || false,
|
||||||
attempt: event.detail.attempt || 1
|
// attempt: event.detail.attempt || 1
|
||||||
});
|
// });
|
||||||
setFileUpdateTrigger(prev => {
|
setFileUpdateTrigger(prev => {
|
||||||
const newTrigger = prev + 1;
|
const newTrigger = prev + 1;
|
||||||
console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||||
old: prev,
|
// old: prev,
|
||||||
new: newTrigger,
|
// new: newTrigger,
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
attempt: event.detail.attempt || 1
|
// attempt: event.detail.attempt || 1
|
||||||
});
|
// });
|
||||||
return newTrigger;
|
return newTrigger;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ 컴포넌트 ID 불일치:", {
|
// console.log("❌ 컴포넌트 ID 불일치:", {
|
||||||
eventComponentId: event.detail.componentId,
|
// eventComponentId: event.detail.componentId,
|
||||||
currentComponentId: component.id
|
// currentComponentId: component.id
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 강제 업데이트 함수 등록
|
// 강제 업데이트 함수 등록
|
||||||
const forceUpdate = (componentId: string, files: any[]) => {
|
const forceUpdate = (componentId: string, files: any[]) => {
|
||||||
console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||||
targetComponentId: componentId,
|
// targetComponentId: componentId,
|
||||||
currentComponentId: component.id,
|
// currentComponentId: component.id,
|
||||||
isMatch: componentId === component.id,
|
// isMatch: componentId === component.id,
|
||||||
filesCount: files.length
|
// filesCount: files.length
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (componentId === component.id) {
|
if (componentId === component.id) {
|
||||||
console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
filesCount: files.length,
|
// filesCount: files.length,
|
||||||
oldTrigger: fileUpdateTrigger
|
// oldTrigger: fileUpdateTrigger
|
||||||
});
|
// });
|
||||||
setFileUpdateTrigger(prev => {
|
setFileUpdateTrigger(prev => {
|
||||||
const newTrigger = prev + 1;
|
const newTrigger = prev + 1;
|
||||||
console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||||
old: prev,
|
// old: prev,
|
||||||
new: newTrigger,
|
// new: newTrigger,
|
||||||
componentId: component.id
|
// componentId: component.id
|
||||||
});
|
// });
|
||||||
return newTrigger;
|
return newTrigger;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -300,14 +300,14 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
|
// console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -423,16 +423,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
||||||
|
|
||||||
console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
uploadedFilesCount: uploadedFiles.length,
|
// uploadedFilesCount: uploadedFiles.length,
|
||||||
globalFilesCount: globalFiles.length,
|
// globalFilesCount: globalFiles.length,
|
||||||
currentFilesCount: currentFiles.length,
|
// currentFilesCount: currentFiles.length,
|
||||||
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
// currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
||||||
componentType: component.type,
|
// componentType: component.type,
|
||||||
fileUpdateTrigger: fileUpdateTrigger,
|
// fileUpdateTrigger: fileUpdateTrigger,
|
||||||
timestamp: new Date().toISOString()
|
// timestamp: new Date().toISOString()
|
||||||
});
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
? {
|
? {
|
||||||
outline: "2px solid #3b82f6",
|
outline: "2px solid #3b82f6",
|
||||||
outlineOffset: "2px",
|
outlineOffset: "2px",
|
||||||
zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정
|
zIndex: 20, // 패널과 모달보다 낮게 설정
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`component-${id}`}
|
id={`component-${id}`}
|
||||||
className="absolute cursor-pointer"
|
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
||||||
style={{ ...baseStyle, ...selectionStyle }}
|
style={{ ...baseStyle, ...selectionStyle }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
|
|
@ -135,7 +135,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
>
|
>
|
||||||
{/* 동적 컴포넌트 렌더링 */}
|
{/* 동적 컴포넌트 렌더링 */}
|
||||||
<div className={`h-full w-full ${
|
<div className={`h-full w-full ${
|
||||||
component.componentConfig?.type === "table-list" ? "overflow-visible" : ""
|
component.componentConfig?.type === "table-list" ? "overflow-hidden" : ""
|
||||||
}`}>
|
}`}>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
component={component}
|
component={component}
|
||||||
|
|
@ -155,16 +155,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
{/* 선택된 컴포넌트 정보 표시 */}
|
{/* 선택된 컴포넌트 정보 표시 */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
|
<div className="absolute -top-8 left-0 rounded-lg bg-gray-800/90 px-3 py-2 text-xs text-white backdrop-blur-sm shadow-lg">
|
||||||
{type === "widget" && (
|
{type === "widget" && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||||
{(component as WidgetComponent).widgetType || "widget"}
|
<span className="font-medium">{(component as WidgetComponent).widgetType || "widget"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{type !== "widget" && (
|
{type !== "widget" && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<span>{component.componentConfig?.type || type}</span>
|
<span className="font-medium">{component.componentConfig?.type || type}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -201,14 +201,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
|
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
// console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 실제 DB에서 화면의 모든 파일 정보 조회
|
// 실제 DB에서 화면의 모든 파일 정보 조회
|
||||||
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||||
|
|
||||||
if (!fileResponse.success) {
|
if (!fileResponse.success) {
|
||||||
console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
// console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,7 +271,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
// console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
||||||
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId]);
|
}, [selectedScreen?.screenId]);
|
||||||
|
|
@ -723,11 +723,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initComponents = async () => {
|
const initComponents = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🚀 컴포넌트 시스템 초기화 시작...");
|
// console.log("🚀 컴포넌트 시스템 초기화 시작...");
|
||||||
await initializeComponents();
|
await initializeComponents();
|
||||||
console.log("✅ 컴포넌트 시스템 초기화 완료");
|
// console.log("✅ 컴포넌트 시스템 초기화 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
|
// console.error("❌ 컴포넌트 시스템 초기화 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -746,13 +746,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
// console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
||||||
|
|
||||||
// 해당 화면의 모든 파일 조회
|
// 해당 화면의 모든 파일 조회
|
||||||
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||||
|
|
||||||
if (response.success && response.componentFiles) {
|
if (response.success && response.componentFiles) {
|
||||||
console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
// console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||||||
|
|
||||||
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
||||||
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
||||||
|
|
@ -769,7 +769,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
currentLocalStorageFiles = JSON.parse(storedFiles);
|
currentLocalStorageFiles = JSON.parse(storedFiles);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 파일 파싱 실패:", e);
|
// console.warn("localStorage 파일 파싱 실패:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -777,12 +777,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
let finalFiles = serverFiles;
|
let finalFiles = serverFiles;
|
||||||
if (currentGlobalFiles.length > 0) {
|
if (currentGlobalFiles.length > 0) {
|
||||||
finalFiles = currentGlobalFiles;
|
finalFiles = currentGlobalFiles;
|
||||||
console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
|
// console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
|
||||||
} else if (currentLocalStorageFiles.length > 0) {
|
} else if (currentLocalStorageFiles.length > 0) {
|
||||||
finalFiles = currentLocalStorageFiles;
|
finalFiles = currentLocalStorageFiles;
|
||||||
console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
|
// console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
|
||||||
} else {
|
} else {
|
||||||
console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
// console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전역 상태에 파일 저장
|
// 전역 상태에 파일 저장
|
||||||
|
|
@ -821,17 +821,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 화면 파일 복원 완료");
|
// console.log("✅ 화면 파일 복원 완료");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 화면 파일 복원 오류:", error);
|
// console.error("❌ 화면 파일 복원 오류:", error);
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId]);
|
}, [selectedScreen?.screenId]);
|
||||||
|
|
||||||
// 전역 파일 상태 변경 이벤트 리스너
|
// 전역 파일 상태 변경 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
|
// console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
|
||||||
setForceRenderTrigger(prev => prev + 1);
|
setForceRenderTrigger(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -881,7 +881,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
};
|
};
|
||||||
setTables([tableInfo]); // 단일 테이블 정보만 설정
|
setTables([tableInfo]); // 단일 테이블 정보만 설정
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 정보 로드 실패:", error);
|
// console.error("테이블 정보 로드 실패:", error);
|
||||||
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
|
toast.error(`테이블 '${selectedScreen.tableName}' 정보를 불러오는데 실패했습니다.`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -923,13 +923,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
|
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
|
||||||
if (response.screenResolution) {
|
if (response.screenResolution) {
|
||||||
setScreenResolution(response.screenResolution);
|
setScreenResolution(response.screenResolution);
|
||||||
console.log("💾 저장된 해상도 불러옴:", response.screenResolution);
|
// console.log("💾 저장된 해상도 불러옴:", response.screenResolution);
|
||||||
} else {
|
} else {
|
||||||
// 기본 해상도 (Full HD)
|
// 기본 해상도 (Full HD)
|
||||||
const defaultResolution =
|
const defaultResolution =
|
||||||
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0];
|
SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0];
|
||||||
setScreenResolution(defaultResolution);
|
setScreenResolution(defaultResolution);
|
||||||
console.log("🔧 기본 해상도 적용:", defaultResolution);
|
// console.log("🔧 기본 해상도 적용:", defaultResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLayout(layoutWithDefaultGrid);
|
setLayout(layoutWithDefaultGrid);
|
||||||
|
|
@ -940,7 +940,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
// console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -989,8 +989,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
|
|
||||||
newLayout.components = adjustedComponents;
|
newLayout.components = adjustedComponents;
|
||||||
console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
|
// console.log("격자 설정 변경으로 컴포넌트 위치 및 크기 자동 조정:", adjustedComponents.length, "개");
|
||||||
console.log("새로운 격자 정보:", newGridInfo);
|
// console.log("새로운 격자 정보:", newGridInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLayout(newLayout);
|
setLayout(newLayout);
|
||||||
|
|
@ -1033,7 +1033,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용)
|
// 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용)
|
||||||
const handleForceGridUpdate = useCallback(() => {
|
const handleForceGridUpdate = useCallback(() => {
|
||||||
if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) {
|
if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) {
|
||||||
console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음");
|
// console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,7 +1114,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 저장 성공 후 메뉴 할당 모달 열기
|
// 저장 성공 후 메뉴 할당 모달 열기
|
||||||
setShowMenuAssignmentModal(true);
|
setShowMenuAssignmentModal(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 실패:", error);
|
// console.error("저장 실패:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|
@ -1581,7 +1581,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 존 클릭 핸들러
|
// 존 클릭 핸들러
|
||||||
const handleZoneClick = useCallback((zoneId: string) => {
|
const handleZoneClick = useCallback((zoneId: string) => {
|
||||||
console.log("🎯 존 클릭:", zoneId);
|
// console.log("🎯 존 클릭:", zoneId);
|
||||||
// 필요시 존 선택 로직 추가
|
// 필요시 존 선택 로직 추가
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -1648,7 +1648,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("드래그 데이터 파싱 오류:", error);
|
// console.error("드래그 데이터 파싱 오류:", error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1810,15 +1810,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const dragData = e.dataTransfer.getData("application/json");
|
const dragData = e.dataTransfer.getData("application/json");
|
||||||
console.log("🎯 드롭 이벤트:", { dragData });
|
// console.log("🎯 드롭 이벤트:", { dragData });
|
||||||
if (!dragData) {
|
if (!dragData) {
|
||||||
console.log("❌ 드래그 데이터가 없습니다");
|
// console.log("❌ 드래그 데이터가 없습니다");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(dragData);
|
const parsedData = JSON.parse(dragData);
|
||||||
console.log("📋 파싱된 데이터:", parsedData);
|
// console.log("📋 파싱된 데이터:", parsedData);
|
||||||
|
|
||||||
// 템플릿 드래그인 경우
|
// 템플릿 드래그인 경우
|
||||||
if (parsedData.type === "template") {
|
if (parsedData.type === "template") {
|
||||||
|
|
@ -1871,7 +1871,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (type === "column") {
|
} else if (type === "column") {
|
||||||
console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
// console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName });
|
||||||
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
// 현재 해상도에 맞는 격자 정보로 기본 크기 계산
|
||||||
const currentGridInfo = layout.gridSettings
|
const currentGridInfo = layout.gridSettings
|
||||||
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
|
|
@ -2052,7 +2052,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 웹타입을 새로운 컴포넌트 ID로 매핑
|
// 웹타입을 새로운 컴포넌트 ID로 매핑
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 폼 컨테이너 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
|
|
@ -2096,7 +2096,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
} else {
|
} else {
|
||||||
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
|
// 일반 캔버스에 드롭한 경우 - 새로운 컴포넌트 시스템 사용
|
||||||
const componentId = getComponentIdFromWebType(column.widgetType);
|
const componentId = getComponentIdFromWebType(column.widgetType);
|
||||||
console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
// console.log(`🔄 캔버스 드롭: ${column.widgetType} → ${componentId}`);
|
||||||
|
|
||||||
newComponent = {
|
newComponent = {
|
||||||
id: generateComponentId(),
|
id: generateComponentId(),
|
||||||
|
|
@ -2186,7 +2186,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 속성 패널 자동 열기
|
// 속성 패널 자동 열기
|
||||||
openPanel("properties");
|
openPanel("properties");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("드롭 처리 실패:", error);
|
// console.error("드롭 처리 실패:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[layout, gridInfo, saveToHistory, openPanel],
|
[layout, gridInfo, saveToHistory, openPanel],
|
||||||
|
|
@ -2237,7 +2237,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
||||||
console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
// console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -2339,7 +2339,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentsToMove = [...componentsToMove, ...additionalComponents];
|
componentsToMove = [...componentsToMove, ...additionalComponents];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
// console.log("드래그 시작:", component.id, "이동할 컴포넌트 수:", componentsToMove.length);
|
||||||
console.log("마우스 위치:", {
|
console.log("마우스 위치:", {
|
||||||
clientX: event.clientX,
|
clientX: event.clientX,
|
||||||
clientY: event.clientY,
|
clientY: event.clientY,
|
||||||
|
|
@ -2706,7 +2706,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const deleteComponent = useCallback(() => {
|
const deleteComponent = useCallback(() => {
|
||||||
// 다중 선택된 컴포넌트가 있는 경우
|
// 다중 선택된 컴포넌트가 있는 경우
|
||||||
if (groupState.selectedComponents.length > 0) {
|
if (groupState.selectedComponents.length > 0) {
|
||||||
console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개");
|
// console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개");
|
||||||
|
|
||||||
let newComponents = [...layout.components];
|
let newComponents = [...layout.components];
|
||||||
|
|
||||||
|
|
@ -2751,7 +2751,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 단일 선택된 컴포넌트 삭제
|
// 단일 선택된 컴포넌트 삭제
|
||||||
if (!selectedComponent) return;
|
if (!selectedComponent) return;
|
||||||
|
|
||||||
console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id);
|
// console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id);
|
||||||
|
|
||||||
let newComponents;
|
let newComponents;
|
||||||
|
|
||||||
|
|
@ -2789,12 +2789,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 다중 선택된 컴포넌트들 복사
|
// 다중 선택된 컴포넌트들 복사
|
||||||
const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id));
|
||||||
setClipboard(componentsToCopy);
|
setClipboard(componentsToCopy);
|
||||||
console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개");
|
// console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개");
|
||||||
toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`);
|
toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`);
|
||||||
} else if (selectedComponent) {
|
} else if (selectedComponent) {
|
||||||
// 단일 컴포넌트 복사
|
// 단일 컴포넌트 복사
|
||||||
setClipboard([selectedComponent]);
|
setClipboard([selectedComponent]);
|
||||||
console.log("단일 컴포넌트 복사:", selectedComponent.id);
|
// console.log("단일 컴포넌트 복사:", selectedComponent.id);
|
||||||
toast.success("컴포넌트가 복사되었습니다.");
|
toast.success("컴포넌트가 복사되었습니다.");
|
||||||
}
|
}
|
||||||
}, [selectedComponent, groupState.selectedComponents, layout.components]);
|
}, [selectedComponent, groupState.selectedComponents, layout.components]);
|
||||||
|
|
@ -2837,14 +2837,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
selectedComponents: newComponents.map((comp) => comp.id),
|
selectedComponents: newComponents.map((comp) => comp.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
||||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||||
}, [clipboard, layout, saveToHistory]);
|
}, [clipboard, layout, saveToHistory]);
|
||||||
|
|
||||||
// 그룹 생성 (임시 비활성화)
|
// 그룹 생성 (임시 비활성화)
|
||||||
const handleGroupCreate = useCallback(
|
const handleGroupCreate = useCallback(
|
||||||
(componentIds: string[], title: string, style?: any) => {
|
(componentIds: string[], title: string, style?: any) => {
|
||||||
console.log("그룹 생성 기능이 임시 비활성화되었습니다.");
|
// console.log("그룹 생성 기능이 임시 비활성화되었습니다.");
|
||||||
toast.info("그룹 기능이 임시 비활성화되었습니다.");
|
toast.info("그룹 기능이 임시 비활성화되었습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -3047,13 +3047,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔄 그룹 생성 다이얼로그 표시");
|
// console.log("🔄 그룹 생성 다이얼로그 표시");
|
||||||
setShowGroupCreateDialog(true);
|
setShowGroupCreateDialog(true);
|
||||||
}, [groupState.selectedComponents]);
|
}, [groupState.selectedComponents]);
|
||||||
|
|
||||||
// 그룹 해제 함수 (임시 비활성화)
|
// 그룹 해제 함수 (임시 비활성화)
|
||||||
const ungroupComponents = useCallback(() => {
|
const ungroupComponents = useCallback(() => {
|
||||||
console.log("그룹 해제 기능이 임시 비활성화되었습니다.");
|
// console.log("그룹 해제 기능이 임시 비활성화되었습니다.");
|
||||||
toast.info("그룹 해제 기능이 임시 비활성화되었습니다.");
|
toast.info("그룹 해제 기능이 임시 비활성화되었습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -3166,7 +3166,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
// 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey });
|
// console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey });
|
||||||
|
|
||||||
// 🚫 브라우저 기본 단축키 완전 차단 목록
|
// 🚫 브라우저 기본 단축키 완전 차단 목록
|
||||||
const browserShortcuts = [
|
const browserShortcuts = [
|
||||||
|
|
@ -3219,7 +3219,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isBrowserShortcut) {
|
if (isBrowserShortcut) {
|
||||||
console.log("🚫 브라우저 기본 단축키 차단:", e.key);
|
// console.log("🚫 브라우저 기본 단축키 차단:", e.key);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3229,38 +3229,38 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 1. 그룹 관련 단축키
|
// 1. 그룹 관련 단축키
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
|
||||||
console.log("🔄 그룹 생성 단축키");
|
// console.log("🔄 그룹 생성 단축키");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
if (groupState.selectedComponents.length >= 2) {
|
if (groupState.selectedComponents.length >= 2) {
|
||||||
console.log("✅ 그룹 생성 실행");
|
// console.log("✅ 그룹 생성 실행");
|
||||||
createGroup();
|
createGroup();
|
||||||
} else {
|
} else {
|
||||||
console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)");
|
// console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") {
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") {
|
||||||
console.log("🔄 그룹 해제 단축키");
|
// console.log("🔄 그룹 해제 단축키");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
if (selectedComponent && selectedComponent.type === "group") {
|
if (selectedComponent && selectedComponent.type === "group") {
|
||||||
console.log("✅ 그룹 해제 실행");
|
// console.log("✅ 그룹 해제 실행");
|
||||||
ungroupComponents();
|
ungroupComponents();
|
||||||
} else {
|
} else {
|
||||||
console.log("⚠️ 선택된 그룹이 없음");
|
// console.log("⚠️ 선택된 그룹이 없음");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 전체 선택 (애플리케이션 내에서만)
|
// 2. 전체 선택 (애플리케이션 내에서만)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") {
|
||||||
console.log("🔄 전체 선택 (애플리케이션 내)");
|
// console.log("🔄 전체 선택 (애플리케이션 내)");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3271,7 +3271,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 3. 실행취소/다시실행
|
// 3. 실행취소/다시실행
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) {
|
||||||
console.log("🔄 실행취소");
|
// console.log("🔄 실행취소");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3283,7 +3283,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") ||
|
((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") ||
|
||||||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z")
|
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z")
|
||||||
) {
|
) {
|
||||||
console.log("🔄 다시실행");
|
// console.log("🔄 다시실행");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3293,7 +3293,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 4. 복사 (컴포넌트 복사)
|
// 4. 복사 (컴포넌트 복사)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") {
|
||||||
console.log("🔄 컴포넌트 복사");
|
// console.log("🔄 컴포넌트 복사");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3303,7 +3303,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 5. 붙여넣기 (컴포넌트 붙여넣기)
|
// 5. 붙여넣기 (컴포넌트 붙여넣기)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
|
||||||
console.log("🔄 컴포넌트 붙여넣기");
|
// console.log("🔄 컴포넌트 붙여넣기");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3313,7 +3313,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 6. 삭제 (단일/다중 선택 지원)
|
// 6. 삭제 (단일/다중 선택 지원)
|
||||||
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
||||||
console.log("🗑️ 컴포넌트 삭제 (단축키)");
|
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3323,7 +3323,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 7. 선택 해제
|
// 7. 선택 해제
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
console.log("🔄 선택 해제");
|
// console.log("🔄 선택 해제");
|
||||||
setSelectedComponent(null);
|
setSelectedComponent(null);
|
||||||
setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false }));
|
setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false }));
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -3331,7 +3331,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용)
|
// 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") {
|
||||||
console.log("💾 레이아웃 저장");
|
// console.log("💾 레이아웃 저장");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
@ -3353,13 +3353,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
toast.success("레이아웃이 저장되었습니다.");
|
toast.success("레이아웃이 저장되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 저장 실패:", error);
|
// console.error("레이아웃 저장 실패:", error);
|
||||||
toast.error("레이아웃 저장에 실패했습니다.");
|
toast.error("레이아웃 저장에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("⚠️ 저장할 컴포넌트가 없습니다");
|
// console.log("⚠️ 저장할 컴포넌트가 없습니다");
|
||||||
toast.warning("저장할 컴포넌트가 없습니다.");
|
toast.warning("저장할 컴포넌트가 없습니다.");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -3437,7 +3437,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="relative h-full w-full overflow-visible bg-white" // overflow-visible로 변경
|
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
|
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
|
||||||
setSelectedComponent(null);
|
setSelectedComponent(null);
|
||||||
|
|
@ -3455,7 +3455,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("🎯 캔버스 드롭 이벤트 발생");
|
// console.log("🎯 캔버스 드롭 이벤트 발생");
|
||||||
handleDrop(e);
|
handleDrop(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -3502,7 +3502,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
transform: "scale(1.02)",
|
transform: "scale(1.02)",
|
||||||
transition: "none",
|
transition: "none",
|
||||||
zIndex: 9999,
|
zIndex: 50,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3525,7 +3525,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
...component.style,
|
...component.style,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
transition: "none",
|
transition: "none",
|
||||||
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
|
zIndex: 40, // 주 컴포넌트보다 약간 낮게
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -3555,7 +3555,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onZoneClick={handleZoneClick}
|
onZoneClick={handleZoneClick}
|
||||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||||
onConfigChange={(config) => {
|
onConfigChange={(config) => {
|
||||||
console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
||||||
|
|
||||||
// 컴포넌트의 componentConfig 업데이트
|
// 컴포넌트의 componentConfig 업데이트
|
||||||
const updatedComponents = layout.components.map(comp => {
|
const updatedComponents = layout.components.map(comp => {
|
||||||
|
|
@ -3610,7 +3610,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
transform: "scale(1.02)",
|
transform: "scale(1.02)",
|
||||||
transition: "none",
|
transition: "none",
|
||||||
zIndex: 9999,
|
zIndex: 50,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3667,7 +3667,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onZoneClick={handleZoneClick}
|
onZoneClick={handleZoneClick}
|
||||||
// 설정 변경 핸들러 (자식 컴포넌트용)
|
// 설정 변경 핸들러 (자식 컴포넌트용)
|
||||||
onConfigChange={(config) => {
|
onConfigChange={(config) => {
|
||||||
console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
|
// console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
|
||||||
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -3732,13 +3732,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
onDragStart={(e, table, column) => {
|
onDragStart={(e, table, column) => {
|
||||||
console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
|
// console.log("🚀 드래그 시작:", { table: table.tableName, column: column?.columnName });
|
||||||
const dragData = {
|
const dragData = {
|
||||||
type: column ? "column" : "table",
|
type: column ? "column" : "table",
|
||||||
table,
|
table,
|
||||||
column,
|
column,
|
||||||
};
|
};
|
||||||
console.log("📦 드래그 데이터:", dragData);
|
// console.log("📦 드래그 데이터:", dragData);
|
||||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||||
}}
|
}}
|
||||||
selectedTableName={selectedScreen.tableName}
|
selectedTableName={selectedScreen.tableName}
|
||||||
|
|
@ -3984,7 +3984,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onClose={() => setShowMenuAssignmentModal(false)}
|
onClose={() => setShowMenuAssignmentModal(false)}
|
||||||
screenInfo={selectedScreen}
|
screenInfo={selectedScreen}
|
||||||
onAssignmentComplete={() => {
|
onAssignmentComplete={() => {
|
||||||
console.log("메뉴 할당 완료");
|
// console.log("메뉴 할당 완료");
|
||||||
// 필요시 추가 작업 수행
|
// 필요시 추가 작업 수행
|
||||||
}}
|
}}
|
||||||
onBackToList={onBackToList}
|
onBackToList={onBackToList}
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
|
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
|
||||||
setTables(response.data || []);
|
setTables(response.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 정보 로드 실패:", error);
|
// console.error("테이블 정보 로드 실패:", error);
|
||||||
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -247,7 +247,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
// console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -271,7 +271,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
toast.error("저장에 실패했습니다.");
|
toast.error("저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 실패:", error);
|
// console.error("저장 실패:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|
@ -346,7 +346,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 속성 패널 자동 열기
|
// 속성 패널 자동 열기
|
||||||
openPanel("properties");
|
openPanel("properties");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("드롭 처리 실패:", error);
|
// console.error("드롭 처리 실패:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[layout, gridInfo, saveToHistory, openPanel],
|
[layout, gridInfo, saveToHistory, openPanel],
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`);
|
// console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`);
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
// 최적화된 단일 테이블 조회 API 사용
|
// 최적화된 단일 테이블 조회 API 사용
|
||||||
|
|
@ -421,29 +421,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
});
|
});
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`);
|
// console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setTables([data.data]);
|
setTables([data.data]);
|
||||||
console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`);
|
// console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`);
|
||||||
} else {
|
} else {
|
||||||
console.error("테이블 조회 실패:", data.message);
|
// console.error("테이블 조회 실패:", data.message);
|
||||||
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||||||
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||||||
}
|
}
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`);
|
// console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`);
|
||||||
// 테이블이 존재하지 않는 경우 임시 데이터 생성
|
// 테이블이 존재하지 않는 경우 임시 데이터 생성
|
||||||
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||||||
} else {
|
} else {
|
||||||
console.error("테이블 조회 실패:", response.status);
|
// console.error("테이블 조회 실패:", response.status);
|
||||||
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||||||
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 조회 중 오류:", error);
|
// console.error("테이블 조회 중 오류:", error);
|
||||||
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
// 선택된 화면의 테이블에 대한 임시 데이터 생성
|
||||||
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
setTables([createMockTableForScreen(selectedScreen.tableName)]);
|
||||||
}
|
}
|
||||||
|
|
@ -562,7 +562,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 웹타입에 따른 위젯 타입 매핑
|
// 웹타입에 따른 위젯 타입 매핑
|
||||||
const getWidgetTypeFromWebType = useCallback((webType: string): string => {
|
const getWidgetTypeFromWebType = useCallback((webType: string): string => {
|
||||||
console.log("getWidgetTypeFromWebType - input webType:", webType);
|
// console.log("getWidgetTypeFromWebType - input webType:", webType);
|
||||||
switch (webType) {
|
switch (webType) {
|
||||||
case "text":
|
case "text":
|
||||||
return "text";
|
return "text";
|
||||||
|
|
@ -599,7 +599,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
case "file":
|
case "file":
|
||||||
return "file";
|
return "file";
|
||||||
default:
|
default:
|
||||||
console.log("getWidgetTypeFromWebType - default case, returning text for:", webType);
|
// console.log("getWidgetTypeFromWebType - default case, returning text for:", webType);
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -986,7 +986,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제
|
setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제
|
||||||
toast.success("레이아웃이 성공적으로 저장되었습니다.");
|
toast.success("레이아웃이 성공적으로 저장되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 저장 실패:", error);
|
// console.error("레이아웃 저장 실패:", error);
|
||||||
toast.error("레이아웃 저장에 실패했습니다.");
|
toast.error("레이아웃 저장에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|
@ -1027,7 +1027,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
// console.error("레이아웃 로드 실패:", error);
|
||||||
// 에러 시에도 기본 레이아웃으로 초기화
|
// 에러 시에도 기본 레이아웃으로 초기화
|
||||||
const defaultLayout = {
|
const defaultLayout = {
|
||||||
components: [],
|
components: [],
|
||||||
|
|
@ -1282,7 +1282,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
saveToHistory(newLayout);
|
saveToHistory(newLayout);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("드롭 처리 중 오류:", error);
|
// console.error("드롭 처리 중 오류:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setDragState({
|
setDragState({
|
||||||
|
|
@ -1686,9 +1686,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
|
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
console.log("Drag start - column:", column.columnName, "webType:", column.webType);
|
// console.log("Drag start - column:", column.columnName, "webType:", column.webType);
|
||||||
const widgetType = getWidgetTypeFromWebType(column.webType || "text");
|
const widgetType = getWidgetTypeFromWebType(column.webType || "text");
|
||||||
console.log("Drag start - widgetType:", widgetType);
|
// console.log("Drag start - widgetType:", widgetType);
|
||||||
startDrag(
|
startDrag(
|
||||||
{
|
{
|
||||||
type: "widget",
|
type: "widget",
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("화면 목록 조회 실패", e);
|
// console.error("화면 목록 조회 실패", e);
|
||||||
if (activeTab === "active") {
|
if (activeTab === "active") {
|
||||||
setScreens([]);
|
setScreens([]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -127,7 +127,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setScreens(resp.data || []);
|
setScreens(resp.data || []);
|
||||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("화면 목록 조회 실패", e);
|
// console.error("화면 목록 조회 실패", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
const handleEdit = (screen: ScreenDefinition) => {
|
const handleEdit = (screen: ScreenDefinition) => {
|
||||||
// 편집 모달 열기
|
// 편집 모달 열기
|
||||||
console.log("편집:", screen);
|
// console.log("편집:", screen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (screen: ScreenDefinition) => {
|
const handleDelete = async (screen: ScreenDefinition) => {
|
||||||
|
|
@ -157,7 +157,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("의존성 체크 실패:", error);
|
// console.error("의존성 체크 실패:", error);
|
||||||
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
|
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -177,7 +177,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setDeleteReason("");
|
setDeleteReason("");
|
||||||
setDependencies([]);
|
setDependencies([]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("화면 삭제 실패:", error);
|
// console.error("화면 삭제 실패:", error);
|
||||||
|
|
||||||
// 의존성 오류인 경우 경고창 표시
|
// 의존성 오류인 경우 경고창 표시
|
||||||
if (error.response?.status === 409 && error.response?.data?.code === "SCREEN_HAS_DEPENDENCIES") {
|
if (error.response?.status === 409 && error.response?.data?.code === "SCREEN_HAS_DEPENDENCIES") {
|
||||||
|
|
@ -208,7 +208,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setActiveTab("active");
|
setActiveTab("active");
|
||||||
reloadScreens();
|
reloadScreens();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 복원 실패:", error);
|
// console.error("화면 복원 실패:", error);
|
||||||
alert("화면 복원에 실패했습니다.");
|
alert("화면 복원에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -227,7 +227,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setPermanentDeleteDialogOpen(false);
|
setPermanentDeleteDialogOpen(false);
|
||||||
setScreenToPermanentDelete(null);
|
setScreenToPermanentDelete(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 영구 삭제 실패:", error);
|
// console.error("화면 영구 삭제 실패:", error);
|
||||||
alert("화면 영구 삭제에 실패했습니다.");
|
alert("화면 영구 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -283,7 +283,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
alert(message);
|
alert(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("일괄 삭제 실패:", error);
|
// console.error("일괄 삭제 실패:", error);
|
||||||
alert("일괄 삭제에 실패했습니다.");
|
alert("일괄 삭제에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setBulkDeleting(false);
|
setBulkDeleting(false);
|
||||||
|
|
@ -297,7 +297,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
const handleView = (screen: ScreenDefinition) => {
|
const handleView = (screen: ScreenDefinition) => {
|
||||||
// 미리보기 모달 열기
|
// 미리보기 모달 열기
|
||||||
console.log("미리보기:", screen);
|
// console.log("미리보기:", screen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopySuccess = () => {
|
const handleCopySuccess = () => {
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export default function ScreenPreview({ layout, screenName, className }: ScreenP
|
||||||
|
|
||||||
// 폼 데이터 출력
|
// 폼 데이터 출력
|
||||||
const logFormData = () => {
|
const logFormData = () => {
|
||||||
console.log("폼 데이터:", formData);
|
// console.log("폼 데이터:", formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default function SimpleScreenDesigner({ selectedScreen, onBackToList }: S
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
toast.success("화면이 저장되었습니다.");
|
toast.success("화면이 저장되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 실패:", error);
|
// console.error("저장 실패:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export default function TableTypeSelector({
|
||||||
const tableList = await tableTypeApi.getTables();
|
const tableList = await tableTypeApi.getTables();
|
||||||
setTables(tableList);
|
setTables(tableList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 조회 실패:", error);
|
// console.error("테이블 목록 조회 실패:", error);
|
||||||
// API 호출 실패 시 기본 테이블 목록 사용
|
// API 호출 실패 시 기본 테이블 목록 사용
|
||||||
const fallbackTables = [
|
const fallbackTables = [
|
||||||
{ tableName: "user_info", displayName: "사용자 정보", description: "사용자 기본 정보", columnCount: "25" },
|
{ tableName: "user_info", displayName: "사용자 정보", description: "사용자 기본 정보", columnCount: "25" },
|
||||||
|
|
@ -89,7 +89,7 @@ export default function TableTypeSelector({
|
||||||
|
|
||||||
setColumns(formattedColumns);
|
setColumns(formattedColumns);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 정보 조회 실패:", error);
|
// console.error("컬럼 정보 조회 실패:", error);
|
||||||
// API 호출 실패 시 기본 컬럼 정보 사용
|
// API 호출 실패 시 기본 컬럼 정보 사용
|
||||||
const fallbackColumns: ColumnInfo[] = [
|
const fallbackColumns: ColumnInfo[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -167,9 +167,9 @@ export default function TableTypeSelector({
|
||||||
// 로컬 상태 업데이트
|
// 로컬 상태 업데이트
|
||||||
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, webType } : col)));
|
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, webType } : col)));
|
||||||
|
|
||||||
console.log(`컬럼 ${columnName}의 웹 타입을 ${webType}로 변경했습니다.`);
|
// console.log(`컬럼 ${columnName}의 웹 타입을 ${webType}로 변경했습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("웹 타입 설정 실패:", error);
|
// console.error("웹 타입 설정 실패:", error);
|
||||||
alert("웹 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
alert("웹 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -193,9 +193,9 @@ export default function TableTypeSelector({
|
||||||
// 로컬 상태 업데이트
|
// 로컬 상태 업데이트
|
||||||
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
|
setColumns((prev) => prev.map((col) => (col.columnName === columnName ? { ...col, inputType } : col)));
|
||||||
|
|
||||||
console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`);
|
// console.log(`컬럼 ${columnName}의 입력 타입을 ${inputType}로 변경했습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("입력 타입 변경 실패:", error);
|
// console.error("입력 타입 변경 실패:", error);
|
||||||
alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
alert("입력 타입 설정에 실패했습니다. 다시 시도해주세요.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export default function TemplateManager({
|
||||||
});
|
});
|
||||||
setTemplates(templateList);
|
setTemplates(templateList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("템플릿 목록 조회 실패:", error);
|
// console.error("템플릿 목록 조회 실패:", error);
|
||||||
// API 호출 실패 시 기본 템플릿 목록 사용
|
// API 호출 실패 시 기본 템플릿 목록 사용
|
||||||
const fallbackTemplates: ScreenTemplate[] = [
|
const fallbackTemplates: ScreenTemplate[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -196,7 +196,7 @@ export default function TemplateManager({
|
||||||
}
|
}
|
||||||
alert("템플릿이 삭제되었습니다.");
|
alert("템플릿이 삭제되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("템플릿 삭제 실패:", error);
|
// console.error("템플릿 삭제 실패:", error);
|
||||||
alert("템플릿 삭제에 실패했습니다. 다시 시도해주세요.");
|
alert("템플릿 삭제에 실패했습니다. 다시 시도해주세요.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -204,7 +204,7 @@ export default function TemplateManager({
|
||||||
// 새 템플릿 생성
|
// 새 템플릿 생성
|
||||||
const handleCreateTemplate = () => {
|
const handleCreateTemplate = () => {
|
||||||
// TODO: 새 템플릿 생성 모달 또는 페이지로 이동
|
// TODO: 새 템플릿 생성 모달 또는 페이지로 이동
|
||||||
console.log("새 템플릿 생성");
|
// console.log("새 템플릿 생성");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 템플릿 내보내기
|
// 템플릿 내보내기
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
const fetchScreens = async () => {
|
const fetchScreens = async () => {
|
||||||
try {
|
try {
|
||||||
setScreensLoading(true);
|
setScreensLoading(true);
|
||||||
console.log("🔍 화면 목록 API 호출 시작");
|
// console.log("🔍 화면 목록 API 호출 시작");
|
||||||
const response = await apiClient.get("/screen-management/screens");
|
const response = await apiClient.get("/screen-management/screens");
|
||||||
console.log("✅ 화면 목록 API 응답:", response.data);
|
// console.log("✅ 화면 목록 API 응답:", response.data);
|
||||||
|
|
||||||
if (response.data.success && Array.isArray(response.data.data)) {
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
const screenList = response.data.data.map((screen: any) => ({
|
const screenList = response.data.data.map((screen: any) => ({
|
||||||
|
|
@ -50,10 +50,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
description: screen.description,
|
description: screen.description,
|
||||||
}));
|
}));
|
||||||
setScreens(screenList);
|
setScreens(screenList);
|
||||||
console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
|
// console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 화면 목록 로딩 실패:", error);
|
// console.error("❌ 화면 목록 로딩 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setScreensLoading(false);
|
setScreensLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
const loadDiagrams = async () => {
|
const loadDiagrams = async () => {
|
||||||
try {
|
try {
|
||||||
setDiagramsLoading(true);
|
setDiagramsLoading(true);
|
||||||
console.log("🔍 데이터플로우 관계 목록 로딩...");
|
// console.log("🔍 데이터플로우 관계 목록 로딩...");
|
||||||
|
|
||||||
const response = await apiClient.get("/test-button-dataflow/diagrams");
|
const response = await apiClient.get("/test-button-dataflow/diagrams");
|
||||||
|
|
||||||
|
|
@ -90,10 +90,10 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setDiagrams(diagramList);
|
setDiagrams(diagramList);
|
||||||
console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`);
|
// console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 관계 목록 로딩 실패:", error);
|
// console.error("❌ 관계 목록 로딩 실패:", error);
|
||||||
setDiagrams([]);
|
setDiagrams([]);
|
||||||
} finally {
|
} finally {
|
||||||
setDiagramsLoading(false);
|
setDiagramsLoading(false);
|
||||||
|
|
@ -106,15 +106,15 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
const loadRelationships = async (diagramId: number) => {
|
const loadRelationships = async (diagramId: number) => {
|
||||||
try {
|
try {
|
||||||
setRelationshipsLoading(true);
|
setRelationshipsLoading(true);
|
||||||
console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`);
|
// console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
|
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
|
||||||
|
|
||||||
if (response.data.success && Array.isArray(response.data.data)) {
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
console.log("🔍 백엔드에서 받은 관계 데이터:", response.data.data);
|
// console.log("🔍 백엔드에서 받은 관계 데이터:", response.data.data);
|
||||||
|
|
||||||
const relationshipList = response.data.data.map((rel: any) => {
|
const relationshipList = response.data.data.map((rel: any) => {
|
||||||
console.log("🔍 개별 관계 데이터:", rel);
|
// console.log("🔍 개별 관계 데이터:", rel);
|
||||||
|
|
||||||
// 여러 가지 가능한 필드명 시도 (백엔드 로그 기준으로 수정)
|
// 여러 가지 가능한 필드명 시도 (백엔드 로그 기준으로 수정)
|
||||||
const relationshipName =
|
const relationshipName =
|
||||||
|
|
@ -137,15 +137,15 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
category: rel.category || rel.type || "data-save",
|
category: rel.category || rel.type || "data-save",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 매핑된 관계 데이터:", mappedRel);
|
// console.log("🔍 매핑된 관계 데이터:", mappedRel);
|
||||||
return mappedRel;
|
return mappedRel;
|
||||||
});
|
});
|
||||||
|
|
||||||
setRelationships(relationshipList);
|
setRelationships(relationshipList);
|
||||||
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료:`, relationshipList);
|
// console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료:`, relationshipList);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 관계 목록 로딩 실패:", error);
|
// console.error("❌ 관계 목록 로딩 실패:", error);
|
||||||
setRelationships([]);
|
setRelationships([]);
|
||||||
} finally {
|
} finally {
|
||||||
setRelationshipsLoading(false);
|
setRelationshipsLoading(false);
|
||||||
|
|
@ -169,7 +169,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
setPreviewData(response.data.data);
|
setPreviewData(response.data.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 관계 미리보기 로딩 실패:", error);
|
// console.error("❌ 관계 미리보기 로딩 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
const loadRelationships = async () => {
|
const loadRelationships = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
console.log("🔍 전체 관계 목록 로딩...");
|
// console.log("🔍 전체 관계 목록 로딩...");
|
||||||
|
|
||||||
const response = await apiClient.get("/test-button-dataflow/relationships/all");
|
const response = await apiClient.get("/test-button-dataflow/relationships/all");
|
||||||
|
|
||||||
|
|
@ -77,10 +77,10 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setRelationships(relationshipList);
|
setRelationships(relationshipList);
|
||||||
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
|
// console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 관계 목록 로딩 실패:", error);
|
// console.error("❌ 관계 목록 로딩 실패:", error);
|
||||||
setRelationships([]);
|
setRelationships([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,14 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
return tableColumns
|
return tableColumns
|
||||||
.filter((col) => {
|
.filter((col) => {
|
||||||
const webType = col.webType || col.web_type;
|
const webType = col.webType || col.web_type;
|
||||||
return filterableWebTypes.includes(webType) && col.isVisible !== false;
|
const columnName = col.columnName || col.column_name;
|
||||||
|
// 체크박스 컬럼과 __checkbox__ 컬럼, 사번 컬럼 제외
|
||||||
|
return filterableWebTypes.includes(webType) &&
|
||||||
|
col.isVisible !== false &&
|
||||||
|
columnName !== "__checkbox__" &&
|
||||||
|
!columnName.toLowerCase().includes('checkbox') &&
|
||||||
|
!columnName.toLowerCase().includes('sabun') &&
|
||||||
|
!columnName.toLowerCase().includes('사번');
|
||||||
})
|
})
|
||||||
.slice(0, 6) // 최대 6개까지만 자동 생성
|
.slice(0, 6) // 최대 6개까지만 자동 생성
|
||||||
.map((col) => ({
|
.map((col) => ({
|
||||||
|
|
@ -101,7 +108,7 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
|
|
||||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
|
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
|
// console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
|
||||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
|
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
|
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
|
||||||
|
|
@ -127,7 +134,7 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
|
|
||||||
setEntityOptions((prev) => ({ ...prev, [key]: options }));
|
setEntityOptions((prev) => ({ ...prev, [key]: options }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
|
// console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
|
||||||
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
|
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingStates((prev) => ({ ...prev, [key]: false }));
|
setLoadingStates((prev) => ({ ...prev, [key]: false }));
|
||||||
|
|
@ -333,8 +340,7 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filter.columnName} className={`space-y-0.5 ${getFilterWidth()}`}>
|
<div key={filter.columnName} className={`${getFilterWidth()}`}>
|
||||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
|
||||||
{renderFilter(filter)}
|
{renderFilter(filter)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
// 레지스트리에서 모든 컴포넌트 조회
|
// 레지스트리에서 모든 컴포넌트 조회
|
||||||
const allComponents = useMemo(() => {
|
const allComponents = useMemo(() => {
|
||||||
const components = ComponentRegistry.getAllComponents();
|
const components = ComponentRegistry.getAllComponents();
|
||||||
console.log("🔍 ComponentsPanel - 로드된 컴포넌트:", components.map(c => ({ id: c.id, name: c.name, category: c.category })));
|
// console.log("🔍 ComponentsPanel - 로드된 컴포넌트:", components.map(c => ({ id: c.id, name: c.name, category: c.category })));
|
||||||
|
|
||||||
// 수동으로 table-list 컴포넌트 추가 (임시)
|
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||||
const hasTableList = components.some(c => c.id === 'table-list');
|
const hasTableList = components.some(c => c.id === 'table-list');
|
||||||
if (!hasTableList) {
|
if (!hasTableList) {
|
||||||
console.log("⚠️ table-list 컴포넌트가 없어서 수동 추가");
|
// console.log("⚠️ table-list 컴포넌트가 없어서 수동 추가");
|
||||||
components.push({
|
components.push({
|
||||||
id: "table-list",
|
id: "table-list",
|
||||||
name: "테이블 리스트",
|
name: "테이블 리스트",
|
||||||
|
|
@ -92,7 +92,7 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
type: "component",
|
type: "component",
|
||||||
component: component,
|
component: component,
|
||||||
};
|
};
|
||||||
console.log("🚀 컴포넌트 드래그 시작:", component.name, dragData);
|
// console.log("🚀 컴포넌트 드래그 시작:", component.name, dragData);
|
||||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
};
|
};
|
||||||
|
|
@ -185,6 +185,14 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 주황색 강조 영역 */}
|
||||||
|
<div className="mt-4 mb-4 p-4 bg-gradient-to-r from-orange-50 to-amber-50 border border-orange-200 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||||
|
<span className="text-sm font-medium text-orange-800">컴포넌트를 드래그하여 화면에 추가하세요</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컴포넌트 목록 */}
|
{/* 컴포넌트 목록 */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<TabsContent value={selectedCategory} className="space-y-3">
|
<TabsContent value={selectedCategory} className="space-y-3">
|
||||||
|
|
@ -205,7 +213,7 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
e.currentTarget.style.opacity = '1';
|
e.currentTarget.style.opacity = '1';
|
||||||
e.currentTarget.style.transform = 'none';
|
e.currentTarget.style.transform = 'none';
|
||||||
}}
|
}}
|
||||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-6 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||||
title={component.description}
|
title={component.description}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
|
|
|
||||||
|
|
@ -106,47 +106,47 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
|
|
||||||
// 컴포넌트 변경 시 로컬 값 동기화
|
// 컴포넌트 변경 시 로컬 값 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", {
|
// console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
title: component.title,
|
// title: component.title,
|
||||||
searchButtonText: component.searchButtonText,
|
// searchButtonText: component.searchButtonText,
|
||||||
columnsCount: component.columns.length,
|
// columnsCount: component.columns.length,
|
||||||
filtersCount: component.filters.length,
|
// filtersCount: component.filters.length,
|
||||||
columnIds: component.columns.map((col) => col.id),
|
// columnIds: component.columns.map((col) => col.id),
|
||||||
filterColumnNames: component.filters.map((filter) => filter.columnName),
|
// filterColumnNames: component.filters.map((filter) => filter.columnName),
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 컬럼과 필터 상세 정보 로그
|
// 컬럼과 필터 상세 정보 로그
|
||||||
if (component.columns.length > 0) {
|
if (component.columns.length > 0) {
|
||||||
console.log(
|
// console.log(
|
||||||
"📋 현재 컬럼 목록:",
|
// "📋 현재 컬럼 목록:",
|
||||||
component.columns.map((col) => ({
|
// component.columns.map((col) => ({
|
||||||
id: col.id,
|
// id: col.id,
|
||||||
columnName: col.columnName,
|
// columnName: col.columnName,
|
||||||
label: col.label,
|
// label: col.label,
|
||||||
visible: col.visible,
|
// visible: col.visible,
|
||||||
gridColumns: col.gridColumns,
|
// gridColumns: col.gridColumns,
|
||||||
})),
|
// })),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로컬 상태 정보 로그
|
// 로컬 상태 정보 로그
|
||||||
console.log("🔧 로컬 상태 정보:", {
|
// console.log("🔧 로컬 상태 정보:", {
|
||||||
localColumnInputsCount: Object.keys(localColumnInputs).length,
|
// localColumnInputsCount: Object.keys(localColumnInputs).length,
|
||||||
localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length,
|
// localColumnCheckboxesCount: Object.keys(localColumnCheckboxes).length,
|
||||||
localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length,
|
// localColumnGridColumnsCount: Object.keys(localColumnGridColumns).length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (component.filters.length > 0) {
|
if (component.filters.length > 0) {
|
||||||
console.log(
|
// console.log(
|
||||||
"🔍 현재 필터 목록:",
|
// "🔍 현재 필터 목록:",
|
||||||
component.filters.map((filter) => ({
|
// component.filters.map((filter) => ({
|
||||||
columnName: filter.columnName,
|
// columnName: filter.columnName,
|
||||||
widgetType: filter.widgetType,
|
// widgetType: filter.widgetType,
|
||||||
label: filter.label,
|
// label: filter.label,
|
||||||
})),
|
// })),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalValues({
|
setLocalValues({
|
||||||
|
|
@ -252,11 +252,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
const filterKey = `${filter.columnName}-${index}`;
|
const filterKey = `${filter.columnName}-${index}`;
|
||||||
if (!(filterKey in newFilterInputs)) {
|
if (!(filterKey in newFilterInputs)) {
|
||||||
newFilterInputs[filterKey] = filter.label || filter.columnName;
|
newFilterInputs[filterKey] = filter.label || filter.columnName;
|
||||||
console.log("🆕 새 필터 로컬 상태 추가:", {
|
// console.log("🆕 새 필터 로컬 상태 추가:", {
|
||||||
filterKey,
|
// filterKey,
|
||||||
label: filter.label,
|
// label: filter.label,
|
||||||
columnName: filter.columnName,
|
// columnName: filter.columnName,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,16 +266,16 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
);
|
);
|
||||||
Object.keys(newFilterInputs).forEach((key) => {
|
Object.keys(newFilterInputs).forEach((key) => {
|
||||||
if (!currentFilterKeys.has(key)) {
|
if (!currentFilterKeys.has(key)) {
|
||||||
console.log("🗑️ 삭제된 필터 로컬 상태 제거:", { filterKey: key });
|
// console.log("🗑️ 삭제된 필터 로컬 상태 제거:", { filterKey: key });
|
||||||
delete newFilterInputs[key];
|
delete newFilterInputs[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📝 필터 로컬 상태 동기화 완료:", {
|
// console.log("📝 필터 로컬 상태 동기화 완료:", {
|
||||||
prevCount: Object.keys(prev).length,
|
// prevCount: Object.keys(prev).length,
|
||||||
newCount: Object.keys(newFilterInputs).length,
|
// newCount: Object.keys(newFilterInputs).length,
|
||||||
newKeys: Object.keys(newFilterInputs),
|
// newKeys: Object.keys(newFilterInputs),
|
||||||
});
|
// });
|
||||||
|
|
||||||
return newFilterInputs;
|
return newFilterInputs;
|
||||||
});
|
});
|
||||||
|
|
@ -313,20 +313,20 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
const table = tables.find((t) => t.tableName === tableName);
|
const table = tables.find((t) => t.tableName === tableName);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
console.log("🔄 테이블 변경:", {
|
// console.log("🔄 테이블 변경:", {
|
||||||
tableName,
|
// tableName,
|
||||||
currentTableName: localValues.tableName,
|
// currentTableName: localValues.tableName,
|
||||||
table,
|
// table,
|
||||||
columnsCount: table.columns.length,
|
// columnsCount: table.columns.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
||||||
const defaultColumns: DataTableColumn[] = [];
|
const defaultColumns: DataTableColumn[] = [];
|
||||||
|
|
||||||
console.log("✅ 생성된 컬럼 설정:", {
|
// console.log("✅ 생성된 컬럼 설정:", {
|
||||||
defaultColumnsCount: defaultColumns.length,
|
// defaultColumnsCount: defaultColumns.length,
|
||||||
visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
// visibleColumns: defaultColumns.filter((col) => col.visible).length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 상태 업데이트를 한 번에 처리
|
// 상태 업데이트를 한 번에 처리
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -363,7 +363,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
async (columnId: string, webTypeConfig: any) => {
|
async (columnId: string, webTypeConfig: any) => {
|
||||||
// 1. 먼저 화면 컴포넌트의 컬럼 설정 업데이트
|
// 1. 먼저 화면 컴포넌트의 컬럼 설정 업데이트
|
||||||
const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, webTypeConfig } : col));
|
const updatedColumns = component.columns.map((col) => (col.id === columnId ? { ...col, webTypeConfig } : col));
|
||||||
console.log("🔄 컬럼 상세 설정 업데이트:", { columnId, webTypeConfig, updatedColumns });
|
// console.log("🔄 컬럼 상세 설정 업데이트:", { columnId, webTypeConfig, updatedColumns });
|
||||||
onUpdateComponent({ columns: updatedColumns });
|
onUpdateComponent({ columns: updatedColumns });
|
||||||
|
|
||||||
// 2. 테이블 타입 관리에도 반영 (라디오 타입인 경우)
|
// 2. 테이블 타입 관리에도 반영 (라디오 타입인 경우)
|
||||||
|
|
@ -371,14 +371,14 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
if (targetColumn && targetColumn.widgetType === "radio" && selectedTable) {
|
if (targetColumn && targetColumn.widgetType === "radio" && selectedTable) {
|
||||||
try {
|
try {
|
||||||
// TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트
|
// TODO: 테이블 타입 관리 API 호출하여 웹 타입과 상세 설정 업데이트
|
||||||
console.log("📡 테이블 타입 관리 업데이트 필요:", {
|
// console.log("📡 테이블 타입 관리 업데이트 필요:", {
|
||||||
tableName: component.tableName,
|
// tableName: component.tableName,
|
||||||
columnName: targetColumn.columnName,
|
// columnName: targetColumn.columnName,
|
||||||
webType: "radio",
|
// webType: "radio",
|
||||||
detailSettings: JSON.stringify(webTypeConfig),
|
// detailSettings: JSON.stringify(webTypeConfig),
|
||||||
});
|
// });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 타입 관리 업데이트 실패:", error);
|
// console.error("테이블 타입 관리 업데이트 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -731,11 +731,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
return newGridColumns;
|
return newGridColumns;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🗑️ 컬럼 삭제:", {
|
// console.log("🗑️ 컬럼 삭제:", {
|
||||||
columnId,
|
// columnId,
|
||||||
columnName: columnToRemove?.columnName,
|
// columnName: columnToRemove?.columnName,
|
||||||
remainingColumns: updatedColumns.length,
|
// remainingColumns: updatedColumns.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
columns: updatedColumns,
|
columns: updatedColumns,
|
||||||
|
|
@ -748,7 +748,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
const updateFilter = useCallback(
|
const updateFilter = useCallback(
|
||||||
(index: number, updates: Partial<DataTableFilter>) => {
|
(index: number, updates: Partial<DataTableFilter>) => {
|
||||||
const updatedFilters = component.filters.map((filter, i) => (i === index ? { ...filter, ...updates } : filter));
|
const updatedFilters = component.filters.map((filter, i) => (i === index ? { ...filter, ...updates } : filter));
|
||||||
console.log("🔄 필터 업데이트:", { index, updates, updatedFilters });
|
// console.log("🔄 필터 업데이트:", { index, updates, updatedFilters });
|
||||||
onUpdateComponent({ filters: updatedFilters });
|
onUpdateComponent({ filters: updatedFilters });
|
||||||
},
|
},
|
||||||
[component.filters, onUpdateComponent],
|
[component.filters, onUpdateComponent],
|
||||||
|
|
@ -782,30 +782,30 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
displayColumn: targetColumn.displayColumn,
|
displayColumn: targetColumn.displayColumn,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("➕ 필터 추가 시작:", {
|
// console.log("➕ 필터 추가 시작:", {
|
||||||
targetColumnName: targetColumn.columnName,
|
// targetColumnName: targetColumn.columnName,
|
||||||
targetColumnLabel: targetColumn.columnLabel,
|
// targetColumnLabel: targetColumn.columnLabel,
|
||||||
inferredWidgetType: widgetType,
|
// inferredWidgetType: widgetType,
|
||||||
currentFiltersCount: component.filters.length,
|
// currentFiltersCount: component.filters.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
console.log("➕ 생성된 새 필터:", {
|
// console.log("➕ 생성된 새 필터:", {
|
||||||
columnName: newFilter.columnName,
|
// columnName: newFilter.columnName,
|
||||||
widgetType: newFilter.widgetType,
|
// widgetType: newFilter.widgetType,
|
||||||
label: newFilter.label,
|
// label: newFilter.label,
|
||||||
gridColumns: newFilter.gridColumns,
|
// gridColumns: newFilter.gridColumns,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const updatedFilters = [...component.filters, newFilter];
|
const updatedFilters = [...component.filters, newFilter];
|
||||||
console.log("🔄 필터 업데이트 호출:", {
|
// console.log("🔄 필터 업데이트 호출:", {
|
||||||
filtersToAdd: 1,
|
// filtersToAdd: 1,
|
||||||
totalFiltersAfter: updatedFilters.length,
|
// totalFiltersAfter: updatedFilters.length,
|
||||||
updatedFilters: updatedFilters.map((filter) => ({
|
// updatedFilters: updatedFilters.map((filter) => ({
|
||||||
columnName: filter.columnName,
|
// columnName: filter.columnName,
|
||||||
widgetType: filter.widgetType,
|
// widgetType: filter.widgetType,
|
||||||
label: filter.label,
|
// label: filter.label,
|
||||||
})),
|
// })),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 먼저 로컬 상태를 업데이트하고
|
// 먼저 로컬 상태를 업데이트하고
|
||||||
const filterKey = `${newFilter.columnName}-${component.filters.length}`;
|
const filterKey = `${newFilter.columnName}-${component.filters.length}`;
|
||||||
|
|
@ -814,12 +814,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
...prev,
|
...prev,
|
||||||
[filterKey]: newFilter.label,
|
[filterKey]: newFilter.label,
|
||||||
};
|
};
|
||||||
console.log("📝 필터 로컬 상태 업데이트:", {
|
// console.log("📝 필터 로컬 상태 업데이트:", {
|
||||||
filterKey,
|
// filterKey,
|
||||||
newLabel: newFilter.label,
|
// newLabel: newFilter.label,
|
||||||
prevState: prev,
|
// prevState: prev,
|
||||||
newState,
|
// newState,
|
||||||
});
|
// });
|
||||||
return newState;
|
return newState;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -829,10 +829,10 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
// 필터 추가 후 필터 탭으로 자동 이동
|
// 필터 추가 후 필터 탭으로 자동 이동
|
||||||
setActiveTab("filters");
|
setActiveTab("filters");
|
||||||
|
|
||||||
console.log("🔍 필터 추가 후 탭 이동:", {
|
// console.log("🔍 필터 추가 후 탭 이동:", {
|
||||||
activeTab: "filters",
|
// activeTab: "filters",
|
||||||
isExternalControl: !!onTabChange,
|
// isExternalControl: !!onTabChange,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 강제로 리렌더링을 트리거하기 위해 여러 방법 사용
|
// 강제로 리렌더링을 트리거하기 위해 여러 방법 사용
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -840,23 +840,23 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
...prev,
|
...prev,
|
||||||
[filterKey]: newFilter.label,
|
[filterKey]: newFilter.label,
|
||||||
}));
|
}));
|
||||||
console.log("🔄 setTimeout에서 강제 로컬 상태 업데이트:", { filterKey, label: newFilter.label });
|
// console.log("🔄 setTimeout에서 강제 로컬 상태 업데이트:", { filterKey, label: newFilter.label });
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// 추가적인 강제 업데이트
|
// 추가적인 강제 업데이트
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLocalFilterInputs((prev) => {
|
setLocalFilterInputs((prev) => {
|
||||||
const updated = { ...prev, [filterKey]: newFilter.label };
|
const updated = { ...prev, [filterKey]: newFilter.label };
|
||||||
console.log("🔄 두 번째 강제 업데이트:", { updated });
|
// console.log("🔄 두 번째 강제 업데이트:", { updated });
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
console.log("✅ 필터 추가 완료 - 로컬 상태와 컴포넌트 모두 업데이트됨", {
|
// console.log("✅ 필터 추가 완료 - 로컬 상태와 컴포넌트 모두 업데이트됨", {
|
||||||
filterKey,
|
// filterKey,
|
||||||
newFilterLabel: newFilter.label,
|
// newFilterLabel: newFilter.label,
|
||||||
switchedToTab: "filters",
|
// switchedToTab: "filters",
|
||||||
});
|
// });
|
||||||
}, [selectedTable, component.filters, onUpdateComponent]);
|
}, [selectedTable, component.filters, onUpdateComponent]);
|
||||||
|
|
||||||
// 필터 삭제
|
// 필터 삭제
|
||||||
|
|
@ -916,24 +916,24 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
|
|
||||||
// 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가)
|
// 필터는 자동으로 추가하지 않음 (사용자가 수동으로 추가)
|
||||||
|
|
||||||
console.log("➕ 컬럼 추가 시작:", {
|
// console.log("➕ 컬럼 추가 시작:", {
|
||||||
targetColumnName: targetColumn.columnName,
|
// targetColumnName: targetColumn.columnName,
|
||||||
targetColumnLabel: targetColumn.columnLabel,
|
// targetColumnLabel: targetColumn.columnLabel,
|
||||||
inferredWidgetType: widgetType,
|
// inferredWidgetType: widgetType,
|
||||||
currentColumnsCount: component.columns.length,
|
// currentColumnsCount: component.columns.length,
|
||||||
currentFiltersCount: component.filters.length,
|
// currentFiltersCount: component.filters.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
console.log("➕ 생성된 새 컬럼:", {
|
// console.log("➕ 생성된 새 컬럼:", {
|
||||||
id: newColumn.id,
|
// id: newColumn.id,
|
||||||
columnName: newColumn.columnName,
|
// columnName: newColumn.columnName,
|
||||||
label: newColumn.label,
|
// label: newColumn.label,
|
||||||
widgetType: newColumn.widgetType,
|
// widgetType: newColumn.widgetType,
|
||||||
filterable: newColumn.filterable,
|
// filterable: newColumn.filterable,
|
||||||
visible: newColumn.visible,
|
// visible: newColumn.visible,
|
||||||
sortable: newColumn.sortable,
|
// sortable: newColumn.sortable,
|
||||||
searchable: newColumn.searchable,
|
// searchable: newColumn.searchable,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 필터는 수동으로만 추가
|
// 필터는 수동으로만 추가
|
||||||
|
|
||||||
|
|
@ -943,11 +943,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
...prev,
|
...prev,
|
||||||
[newColumn.id]: newColumn.label,
|
[newColumn.id]: newColumn.label,
|
||||||
};
|
};
|
||||||
console.log("🔄 로컬 컬럼 상태 업데이트:", {
|
// console.log("🔄 로컬 컬럼 상태 업데이트:", {
|
||||||
newColumnId: newColumn.id,
|
// newColumnId: newColumn.id,
|
||||||
newLabel: newColumn.label,
|
// newLabel: newColumn.label,
|
||||||
totalLocalInputs: Object.keys(newInputs).length,
|
// totalLocalInputs: Object.keys(newInputs).length,
|
||||||
});
|
// });
|
||||||
return newInputs;
|
return newInputs;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -972,28 +972,28 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
columns: [...component.columns, newColumn],
|
columns: [...component.columns, newColumn],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔄 컴포넌트 업데이트 호출:", {
|
// console.log("🔄 컴포넌트 업데이트 호출:", {
|
||||||
columnsToAdd: 1,
|
// columnsToAdd: 1,
|
||||||
totalColumnsAfter: updates.columns?.length,
|
// totalColumnsAfter: updates.columns?.length,
|
||||||
hasColumns: !!updates.columns,
|
// hasColumns: !!updates.columns,
|
||||||
updateKeys: Object.keys(updates),
|
// updateKeys: Object.keys(updates),
|
||||||
});
|
// });
|
||||||
|
|
||||||
console.log("🔄 업데이트 상세 내용:", {
|
// console.log("🔄 업데이트 상세 내용:", {
|
||||||
columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })),
|
// columns: updates.columns?.map((col) => ({ id: col.id, columnName: col.columnName, label: col.label })),
|
||||||
});
|
// });
|
||||||
|
|
||||||
onUpdateComponent(updates);
|
onUpdateComponent(updates);
|
||||||
|
|
||||||
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
||||||
setActiveTab("columns");
|
setActiveTab("columns");
|
||||||
|
|
||||||
console.log("📋 컬럼 추가 후 탭 이동:", {
|
// console.log("📋 컬럼 추가 후 탭 이동:", {
|
||||||
activeTab: "columns",
|
// activeTab: "columns",
|
||||||
isExternalControl: !!onTabChange,
|
// isExternalControl: !!onTabChange,
|
||||||
});
|
// });
|
||||||
|
|
||||||
console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨");
|
// console.log("✅ 컬럼 추가 완료 - onUpdateComponent 호출됨");
|
||||||
},
|
},
|
||||||
[selectedTable, component.columns, component.filters, onUpdateComponent],
|
[selectedTable, component.columns, component.filters, onUpdateComponent],
|
||||||
);
|
);
|
||||||
|
|
@ -1022,11 +1022,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📁 가상 파일 컬럼 추가:", {
|
// console.log("📁 가상 파일 컬럼 추가:", {
|
||||||
columnName: newColumn.columnName,
|
// columnName: newColumn.columnName,
|
||||||
label: newColumn.label,
|
// label: newColumn.label,
|
||||||
isVirtualFileColumn: newColumn.isVirtualFileColumn,
|
// isVirtualFileColumn: newColumn.isVirtualFileColumn,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태에 새 컬럼 입력값 추가
|
// 로컬 상태에 새 컬럼 입력값 추가
|
||||||
setLocalColumnInputs((prev) => ({
|
setLocalColumnInputs((prev) => ({
|
||||||
|
|
@ -1060,7 +1060,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
||||||
setActiveTab("columns");
|
setActiveTab("columns");
|
||||||
|
|
||||||
console.log("✅ 가상 파일 컬럼 추가 완료");
|
// console.log("✅ 가상 파일 컬럼 추가 완료");
|
||||||
}, [component.columns, onUpdateComponent]);
|
}, [component.columns, onUpdateComponent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1386,7 +1386,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
value={localValues.gridColumns.toString()}
|
value={localValues.gridColumns.toString()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const gridColumns = parseInt(e.target.value, 10);
|
const gridColumns = parseInt(e.target.value, 10);
|
||||||
console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
// console.log("🔄 테이블 그리드 컬럼 수 변경:", gridColumns);
|
||||||
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
setLocalValues((prev) => ({ ...prev, gridColumns }));
|
||||||
onUpdateComponent({ gridColumns });
|
onUpdateComponent({ gridColumns });
|
||||||
}}
|
}}
|
||||||
|
|
@ -1406,7 +1406,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="show-search-button"
|
id="show-search-button"
|
||||||
checked={localValues.showSearchButton}
|
checked={localValues.showSearchButton}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 검색 버튼 표시 변경:", checked);
|
// console.log("🔄 검색 버튼 표시 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, showSearchButton: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, showSearchButton: checked as boolean }));
|
||||||
onUpdateComponent({ showSearchButton: checked as boolean });
|
onUpdateComponent({ showSearchButton: checked as boolean });
|
||||||
}}
|
}}
|
||||||
|
|
@ -1421,7 +1421,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="enable-export"
|
id="enable-export"
|
||||||
checked={localValues.enableExport}
|
checked={localValues.enableExport}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 내보내기 기능 변경:", checked);
|
// console.log("🔄 내보내기 기능 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, enableExport: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, enableExport: checked as boolean }));
|
||||||
onUpdateComponent({ enableExport: checked as boolean });
|
onUpdateComponent({ enableExport: checked as boolean });
|
||||||
}}
|
}}
|
||||||
|
|
@ -1501,7 +1501,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={localColumnCheckboxes[column.id]?.visible ?? column.visible}
|
checked={localColumnCheckboxes[column.id]?.visible ?? column.visible}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 컬럼 표시 변경:", { columnId: column.id, checked });
|
// console.log("🔄 컬럼 표시 변경:", { columnId: column.id, checked });
|
||||||
setLocalColumnCheckboxes((prev) => ({
|
setLocalColumnCheckboxes((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[column.id]: { ...prev[column.id], visible: checked as boolean },
|
[column.id]: { ...prev[column.id], visible: checked as boolean },
|
||||||
|
|
@ -1582,7 +1582,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
value={(localColumnGridColumns[column.id] ?? column.gridColumns).toString()}
|
value={(localColumnGridColumns[column.id] ?? column.gridColumns).toString()}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const newGridColumns = parseInt(value);
|
const newGridColumns = parseInt(value);
|
||||||
console.log("🔄 컬럼 그리드 컬럼 변경:", { columnId: column.id, newGridColumns });
|
// console.log("🔄 컬럼 그리드 컬럼 변경:", { columnId: column.id, newGridColumns });
|
||||||
setLocalColumnGridColumns((prev) => ({
|
setLocalColumnGridColumns((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[column.id]: newGridColumns,
|
[column.id]: newGridColumns,
|
||||||
|
|
@ -1609,7 +1609,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id={`sortable-${column.id}`}
|
id={`sortable-${column.id}`}
|
||||||
checked={localColumnCheckboxes[column.id]?.sortable ?? column.sortable}
|
checked={localColumnCheckboxes[column.id]?.sortable ?? column.sortable}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 컬럼 정렬 가능 변경:", { columnId: column.id, checked });
|
// console.log("🔄 컬럼 정렬 가능 변경:", { columnId: column.id, checked });
|
||||||
setLocalColumnCheckboxes((prev) => ({
|
setLocalColumnCheckboxes((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[column.id]: { ...prev[column.id], sortable: checked as boolean },
|
[column.id]: { ...prev[column.id], sortable: checked as boolean },
|
||||||
|
|
@ -1627,7 +1627,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id={`searchable-${column.id}`}
|
id={`searchable-${column.id}`}
|
||||||
checked={localColumnCheckboxes[column.id]?.searchable ?? column.searchable}
|
checked={localColumnCheckboxes[column.id]?.searchable ?? column.searchable}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 컬럼 검색 가능 변경:", { columnId: column.id, checked });
|
// console.log("🔄 컬럼 검색 가능 변경:", { columnId: column.id, checked });
|
||||||
setLocalColumnCheckboxes((prev) => ({
|
setLocalColumnCheckboxes((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[column.id]: { ...prev[column.id], searchable: checked as boolean },
|
[column.id]: { ...prev[column.id], searchable: checked as boolean },
|
||||||
|
|
@ -1967,13 +1967,13 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
const filterKey = `${filter.columnName}-${index}`;
|
const filterKey = `${filter.columnName}-${index}`;
|
||||||
const localValue = localFilterInputs[filterKey];
|
const localValue = localFilterInputs[filterKey];
|
||||||
const finalValue = localValue !== undefined ? localValue : filter.label;
|
const finalValue = localValue !== undefined ? localValue : filter.label;
|
||||||
console.log("🎯 필터 입력 값 결정:", {
|
// console.log("🎯 필터 입력 값 결정:", {
|
||||||
filterKey,
|
// filterKey,
|
||||||
localValue,
|
// localValue,
|
||||||
filterLabel: filter.label,
|
// filterLabel: filter.label,
|
||||||
finalValue,
|
// finalValue,
|
||||||
allLocalInputs: Object.keys(localFilterInputs),
|
// allLocalInputs: Object.keys(localFilterInputs),
|
||||||
});
|
// });
|
||||||
return finalValue;
|
return finalValue;
|
||||||
})()}
|
})()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -2109,7 +2109,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="pagination-enabled"
|
id="pagination-enabled"
|
||||||
checked={localValues.paginationEnabled}
|
checked={localValues.paginationEnabled}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 페이지네이션 사용 변경:", checked);
|
// console.log("🔄 페이지네이션 사용 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, paginationEnabled: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, paginationEnabled: checked as boolean }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
pagination: { ...component.pagination, enabled: checked as boolean },
|
pagination: { ...component.pagination, enabled: checked as boolean },
|
||||||
|
|
@ -2151,7 +2151,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="show-page-size-selector"
|
id="show-page-size-selector"
|
||||||
checked={localValues.showPageSizeSelector}
|
checked={localValues.showPageSizeSelector}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 페이지 크기 선택기 표시 변경:", checked);
|
// console.log("🔄 페이지 크기 선택기 표시 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, showPageSizeSelector: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, showPageSizeSelector: checked as boolean }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
@ -2171,7 +2171,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="show-page-info"
|
id="show-page-info"
|
||||||
checked={localValues.showPageInfo}
|
checked={localValues.showPageInfo}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 페이지 정보 표시 변경:", checked);
|
// console.log("🔄 페이지 정보 표시 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, showPageInfo: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, showPageInfo: checked as boolean }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
@ -2191,7 +2191,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
id="show-first-last"
|
id="show-first-last"
|
||||||
checked={localValues.showFirstLast}
|
checked={localValues.showFirstLast}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
console.log("🔄 처음/마지막 버튼 표시 변경:", checked);
|
// console.log("🔄 처음/마지막 버튼 표시 변경:", checked);
|
||||||
setLocalValues((prev) => ({ ...prev, showFirstLast: checked as boolean }));
|
setLocalValues((prev) => ({ ...prev, showFirstLast: checked as boolean }));
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,17 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
||||||
console.log(`🔍 DetailSettingsPanel props:`, {
|
// console.log(`🔍 DetailSettingsPanel props:`, {
|
||||||
selectedComponent: selectedComponent?.id,
|
// selectedComponent: selectedComponent?.id,
|
||||||
componentType: selectedComponent?.type,
|
// componentType: selectedComponent?.type,
|
||||||
currentTableName,
|
// currentTableName,
|
||||||
currentTable: currentTable?.tableName,
|
// currentTable: currentTable?.tableName,
|
||||||
selectedComponentTableName: selectedComponent?.tableName,
|
// selectedComponentTableName: selectedComponent?.tableName,
|
||||||
});
|
// });
|
||||||
console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
|
// console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
|
||||||
console.log(`🔍 webTypes:`, webTypes);
|
// console.log(`🔍 webTypes:`, webTypes);
|
||||||
console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent);
|
// console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent);
|
||||||
console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType);
|
// console.log(`🔍 DetailSettingsPanel selectedComponent.widgetType:`, selectedComponent?.widgetType);
|
||||||
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
|
const inputableWebTypes = webTypes.map((wt) => wt.web_type);
|
||||||
|
|
||||||
// 레이아웃 컴포넌트 설정 렌더링 함수
|
// 레이아웃 컴포넌트 설정 렌더링 함수
|
||||||
|
|
@ -198,7 +198,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
value={layoutComponent.layoutConfig?.flexbox?.direction || "row"}
|
value={layoutComponent.layoutConfig?.flexbox?.direction || "row"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newDirection = e.target.value;
|
const newDirection = e.target.value;
|
||||||
console.log("🔄 플렉스박스 방향 변경:", newDirection);
|
// console.log("🔄 플렉스박스 방향 변경:", newDirection);
|
||||||
|
|
||||||
// 방향 설정 업데이트
|
// 방향 설정 업데이트
|
||||||
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.direction", newDirection);
|
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.direction", newDirection);
|
||||||
|
|
@ -217,11 +217,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("🔄 존 크기 자동 조정:", {
|
// console.log("🔄 존 크기 자동 조정:", {
|
||||||
direction: newDirection,
|
// direction: newDirection,
|
||||||
zoneCount,
|
// zoneCount,
|
||||||
updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
|
// updatedZones: updatedZones.map((z) => ({ id: z.id, size: z.size })),
|
||||||
});
|
// });
|
||||||
|
|
||||||
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
|
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
|
||||||
}
|
}
|
||||||
|
|
@ -698,27 +698,27 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
const renderWebTypeConfig = (widget: WidgetComponent) => {
|
const renderWebTypeConfig = (widget: WidgetComponent) => {
|
||||||
const currentConfig = widget.webTypeConfig || {};
|
const currentConfig = widget.webTypeConfig || {};
|
||||||
|
|
||||||
console.log("🎨 DetailSettingsPanel renderWebTypeConfig 호출:", {
|
// console.log("🎨 DetailSettingsPanel renderWebTypeConfig 호출:", {
|
||||||
componentId: widget.id,
|
// componentId: widget.id,
|
||||||
widgetType: widget.widgetType,
|
// widgetType: widget.widgetType,
|
||||||
currentConfig,
|
// currentConfig,
|
||||||
configExists: !!currentConfig,
|
// configExists: !!currentConfig,
|
||||||
configKeys: Object.keys(currentConfig),
|
// configKeys: Object.keys(currentConfig),
|
||||||
configStringified: JSON.stringify(currentConfig),
|
// configStringified: JSON.stringify(currentConfig),
|
||||||
widgetWebTypeConfig: widget.webTypeConfig,
|
// widgetWebTypeConfig: widget.webTypeConfig,
|
||||||
widgetWebTypeConfigExists: !!widget.webTypeConfig,
|
// widgetWebTypeConfigExists: !!widget.webTypeConfig,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
console.log("🎨 selectedComponent 전체:", selectedComponent);
|
// console.log("🎨 selectedComponent 전체:", selectedComponent);
|
||||||
|
|
||||||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||||
console.log("🔧 WebTypeConfig 업데이트:", {
|
// console.log("🔧 WebTypeConfig 업데이트:", {
|
||||||
widgetType: widget.widgetType,
|
// widgetType: widget.widgetType,
|
||||||
oldConfig: currentConfig,
|
// oldConfig: currentConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
componentId: widget.id,
|
// componentId: widget.id,
|
||||||
isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
// isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||||
const freshConfig = { ...newConfig };
|
const freshConfig = { ...newConfig };
|
||||||
|
|
@ -727,18 +727,18 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
// 1순위: DB에서 지정된 설정 패널 사용
|
// 1순위: DB에서 지정된 설정 패널 사용
|
||||||
const dbWebType = webTypes.find((wt) => wt.web_type === widget.widgetType);
|
const dbWebType = webTypes.find((wt) => wt.web_type === widget.widgetType);
|
||||||
console.log(`🎨 웹타입 "${widget.widgetType}" DB 조회 결과:`, dbWebType);
|
// console.log(`🎨 웹타입 "${widget.widgetType}" DB 조회 결과:`, dbWebType);
|
||||||
|
|
||||||
if (dbWebType?.config_panel) {
|
if (dbWebType?.config_panel) {
|
||||||
console.log(`🎨 웹타입 "${widget.widgetType}" → DB 지정 설정 패널 "${dbWebType.config_panel}" 사용`);
|
// console.log(`🎨 웹타입 "${widget.widgetType}" → DB 지정 설정 패널 "${dbWebType.config_panel}" 사용`);
|
||||||
const ConfigPanelComponent = getConfigPanelComponent(dbWebType.config_panel);
|
const ConfigPanelComponent = getConfigPanelComponent(dbWebType.config_panel);
|
||||||
console.log(`🎨 getConfigPanelComponent 결과:`, ConfigPanelComponent);
|
// console.log(`🎨 getConfigPanelComponent 결과:`, ConfigPanelComponent);
|
||||||
|
|
||||||
if (ConfigPanelComponent) {
|
if (ConfigPanelComponent) {
|
||||||
console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
// console.log(`🎨 ✅ ConfigPanelComponent 렌더링 시작`);
|
||||||
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
|
return <ConfigPanelComponent config={currentConfig} onConfigChange={handleConfigChange} />;
|
||||||
} else {
|
} else {
|
||||||
console.log(`🎨 ❌ ConfigPanelComponent가 null - 기본 설정 표시`);
|
// console.log(`🎨 ❌ ConfigPanelComponent가 null - 기본 설정 표시`);
|
||||||
return (
|
return (
|
||||||
<div className="py-8 text-center text-gray-500">
|
<div className="py-8 text-center text-gray-500">
|
||||||
⚙️ 기본 설정
|
⚙️ 기본 설정
|
||||||
|
|
@ -748,7 +748,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`🎨 config_panel이 없음 - 기본 설정 표시`);
|
// console.log(`🎨 config_panel이 없음 - 기본 설정 표시`);
|
||||||
return (
|
return (
|
||||||
<div className="py-8 text-center text-gray-500">
|
<div className="py-8 text-center text-gray-500">
|
||||||
⚙️ 기본 설정
|
⚙️ 기본 설정
|
||||||
|
|
@ -771,10 +771,10 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
// 컴포넌트 타입별 설정 패널 렌더링
|
// 컴포넌트 타입별 설정 패널 렌더링
|
||||||
const renderComponentConfigPanel = () => {
|
const renderComponentConfigPanel = () => {
|
||||||
console.log("🔍 renderComponentConfigPanel - selectedComponent:", selectedComponent);
|
// console.log("🔍 renderComponentConfigPanel - selectedComponent:", selectedComponent);
|
||||||
|
|
||||||
if (!selectedComponent) {
|
if (!selectedComponent) {
|
||||||
console.error("❌ selectedComponent가 undefined입니다!");
|
// console.error("❌ selectedComponent가 undefined입니다!");
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||||
<Settings className="mb-4 h-12 w-12 text-red-400" />
|
<Settings className="mb-4 h-12 w-12 text-red-400" />
|
||||||
|
|
@ -835,11 +835,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
// 새로운 컴포넌트 타입들에 대한 설정 패널 확인
|
// 새로운 컴포넌트 타입들에 대한 설정 패널 확인
|
||||||
const componentType = selectedComponent?.componentConfig?.type || selectedComponent?.type;
|
const componentType = selectedComponent?.componentConfig?.type || selectedComponent?.type;
|
||||||
console.log("🔍 DetailSettingsPanel componentType 확인:", {
|
// console.log("🔍 DetailSettingsPanel componentType 확인:", {
|
||||||
selectedComponentType: selectedComponent?.type,
|
// selectedComponentType: selectedComponent?.type,
|
||||||
componentConfigType: selectedComponent?.componentConfig?.type,
|
// componentConfigType: selectedComponent?.componentConfig?.type,
|
||||||
finalComponentType: componentType,
|
// finalComponentType: componentType,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const hasNewConfigPanel =
|
const hasNewConfigPanel =
|
||||||
componentType &&
|
componentType &&
|
||||||
|
|
@ -861,7 +861,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
"badge-status",
|
"badge-status",
|
||||||
].includes(componentType);
|
].includes(componentType);
|
||||||
|
|
||||||
console.log("🔍 hasNewConfigPanel:", hasNewConfigPanel);
|
// console.log("🔍 hasNewConfigPanel:", hasNewConfigPanel);
|
||||||
|
|
||||||
if (hasNewConfigPanel) {
|
if (hasNewConfigPanel) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -944,7 +944,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
// 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
|
// 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
|
||||||
if (selectedComponent.type === "button") {
|
if (selectedComponent.type === "button") {
|
||||||
console.log("🔄 레거시 버튼을 새로운 컴포넌트 시스템으로 변환:", selectedComponent);
|
// console.log("🔄 레거시 버튼을 새로운 컴포넌트 시스템으로 변환:", selectedComponent);
|
||||||
|
|
||||||
// 레거시 버튼을 새로운 시스템으로 변환
|
// 레거시 버튼을 새로운 시스템으로 변환
|
||||||
const convertedComponent = {
|
const convertedComponent = {
|
||||||
|
|
@ -970,7 +970,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||||
const webType = selectedComponent.componentConfig?.webType;
|
const webType = selectedComponent.componentConfig?.webType;
|
||||||
|
|
||||||
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
|
// console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
|
||||||
|
|
||||||
if (!componentId) {
|
if (!componentId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1011,23 +1011,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
componentId={componentId}
|
componentId={componentId}
|
||||||
config={(() => {
|
config={(() => {
|
||||||
const config = selectedComponent.componentConfig || {};
|
const config = selectedComponent.componentConfig || {};
|
||||||
console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config);
|
// console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config);
|
||||||
console.log("🔍 selectedComponent 전체:", selectedComponent);
|
// console.log("🔍 selectedComponent 전체:", selectedComponent);
|
||||||
return config;
|
return config;
|
||||||
})()}
|
})()}
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||||
tableColumns={(() => {
|
tableColumns={(() => {
|
||||||
console.log("🔍 DetailSettingsPanel tableColumns 전달:", {
|
// console.log("🔍 DetailSettingsPanel tableColumns 전달:", {
|
||||||
currentTable,
|
// currentTable,
|
||||||
columns: currentTable?.columns,
|
// columns: currentTable?.columns,
|
||||||
columnsLength: currentTable?.columns?.length,
|
// columnsLength: currentTable?.columns?.length,
|
||||||
sampleColumn: currentTable?.columns?.[0],
|
// sampleColumn: currentTable?.columns?.[0],
|
||||||
deptCodeColumn: currentTable?.columns?.find((col) => col.columnName === "dept_code"),
|
// deptCodeColumn: currentTable?.columns?.find((col) => col.columnName === "dept_code"),
|
||||||
});
|
// });
|
||||||
return currentTable?.columns || [];
|
return currentTable?.columns || [];
|
||||||
})()}
|
})()}
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
// console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
Object.entries(newConfig).forEach(([key, value]) => {
|
Object.entries(newConfig).forEach(([key, value]) => {
|
||||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
currentTable,
|
currentTable,
|
||||||
currentTableName,
|
currentTableName,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
|
// console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
|
||||||
componentId: component?.id,
|
// componentId: component?.id,
|
||||||
componentType: component?.type,
|
// componentType: component?.type,
|
||||||
hasOnUpdateProperty: !!onUpdateProperty,
|
// hasOnUpdateProperty: !!onUpdateProperty,
|
||||||
currentTable,
|
// currentTable,
|
||||||
currentTableName
|
// currentTableName
|
||||||
});
|
// });
|
||||||
// fileConfig가 없는 경우 초기화
|
// fileConfig가 없는 경우 초기화
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!component.fileConfig) {
|
if (!component.fileConfig) {
|
||||||
|
|
@ -90,11 +90,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const currentState = getGlobalFileState();
|
const currentState = getGlobalFileState();
|
||||||
const newState = updater(currentState);
|
const newState = updater(currentState);
|
||||||
(window as any).globalFileState = newState;
|
(window as any).globalFileState = newState;
|
||||||
console.log("🌐 전역 파일 상태 업데이트:", {
|
// console.log("🌐 전역 파일 상태 업데이트:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
newFileCount: newState[component.id]?.length || 0,
|
// newFileCount: newState[component.id]?.length || 0,
|
||||||
totalComponents: Object.keys(newState).length
|
// totalComponents: Object.keys(newState).length
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 강제 리렌더링을 위한 이벤트 발생
|
// 강제 리렌더링을 위한 이벤트 발생
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
|
@ -103,13 +103,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
// 디버깅용 전역 함수 등록
|
// 디버깅용 전역 함수 등록
|
||||||
(window as any).debugFileState = () => {
|
(window as any).debugFileState = () => {
|
||||||
console.log("🔍 전역 파일 상태 디버깅:", {
|
// console.log("🔍 전역 파일 상태 디버깅:", {
|
||||||
globalState: (window as any).globalFileState,
|
// globalState: (window as any).globalFileState,
|
||||||
localStorage: Object.keys(localStorage).filter(key => key.startsWith('fileComponent_')).map(key => ({
|
// localStorage: Object.keys(localStorage).filter(key => key.startsWith('fileComponent_')).map(key => ({
|
||||||
key,
|
// key,
|
||||||
data: JSON.parse(localStorage.getItem(key) || '[]')
|
// data: JSON.parse(localStorage.getItem(key) || '[]')
|
||||||
}))
|
// }))
|
||||||
});
|
// });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -136,7 +136,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
try {
|
try {
|
||||||
parsedBackupFiles = JSON.parse(backupFiles);
|
parsedBackupFiles = JSON.parse(backupFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("백업 파일 파싱 실패:", error);
|
// console.error("백업 파일 파싱 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +144,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
try {
|
try {
|
||||||
parsedTempFiles = JSON.parse(tempBackupFiles);
|
parsedTempFiles = JSON.parse(tempBackupFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("임시 파일 파싱 실패:", error);
|
// console.error("임시 파일 파싱 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
try {
|
try {
|
||||||
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
|
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
|
// console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,19 +164,19 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
parsedTempFiles.length > 0 ? parsedTempFiles :
|
parsedTempFiles.length > 0 ? parsedTempFiles :
|
||||||
componentFiles;
|
componentFiles;
|
||||||
|
|
||||||
console.log("🚀 FileComponentConfigPanel 초기화:", {
|
// console.log("🚀 FileComponentConfigPanel 초기화:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
componentFiles: componentFiles.length,
|
// componentFiles: componentFiles.length,
|
||||||
globalFiles: globalFiles.length,
|
// globalFiles: globalFiles.length,
|
||||||
backupFiles: parsedBackupFiles.length,
|
// backupFiles: parsedBackupFiles.length,
|
||||||
tempFiles: parsedTempFiles.length,
|
// tempFiles: parsedTempFiles.length,
|
||||||
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
|
// fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
|
||||||
finalFiles: finalFiles.length,
|
// finalFiles: finalFiles.length,
|
||||||
source: globalFiles.length > 0 ? 'global' :
|
// source: globalFiles.length > 0 ? 'global' :
|
||||||
parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
|
// parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
|
||||||
parsedBackupFiles.length > 0 ? 'localStorage' :
|
// parsedBackupFiles.length > 0 ? 'localStorage' :
|
||||||
parsedTempFiles.length > 0 ? 'temp' : 'component'
|
// parsedTempFiles.length > 0 ? 'temp' : 'component'
|
||||||
});
|
// });
|
||||||
|
|
||||||
return finalFiles;
|
return finalFiles;
|
||||||
};
|
};
|
||||||
|
|
@ -188,10 +188,10 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onUpdateProperty(component.id, "uploadedFiles", initialFiles);
|
onUpdateProperty(component.id, "uploadedFiles", initialFiles);
|
||||||
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
||||||
console.log("🔄 초기화 시 컴포넌트 속성 동기화:", {
|
// console.log("🔄 초기화 시 컴포넌트 속성 동기화:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
fileCount: initialFiles.length
|
// fileCount: initialFiles.length
|
||||||
});
|
// });
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
return initialFiles;
|
return initialFiles;
|
||||||
|
|
@ -216,15 +216,15 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
// 파일 업로드 처리
|
// 파일 업로드 처리
|
||||||
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
||||||
console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
|
// console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
|
||||||
filesCount: files?.length || 0,
|
// filesCount: files?.length || 0,
|
||||||
componentId: component?.id,
|
// componentId: component?.id,
|
||||||
componentType: component?.type,
|
// componentType: component?.type,
|
||||||
hasOnUpdateProperty: !!onUpdateProperty
|
// hasOnUpdateProperty: !!onUpdateProperty
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
console.log("❌ 파일이 없음");
|
// console.log("❌ 파일이 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,23 +251,23 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
toast.error(`${file.name}: 허용되지 않는 파일 형식입니다. (허용: ${acceptTypes.join(', ')})`);
|
toast.error(`${file.name}: 허용되지 않는 파일 형식입니다. (허용: ${acceptTypes.join(', ')})`);
|
||||||
console.log(`파일 검증 실패:`, {
|
// console.log(`파일 검증 실패:`, {
|
||||||
fileName: file.name,
|
// fileName: file.name,
|
||||||
fileType: file.type,
|
// fileType: file.type,
|
||||||
fileExt,
|
// fileExt,
|
||||||
acceptTypes,
|
// acceptTypes,
|
||||||
isAllowed
|
// isAllowed
|
||||||
});
|
// });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`파일 검증 성공:`, {
|
// console.log(`파일 검증 성공:`, {
|
||||||
fileName: file.name,
|
// fileName: file.name,
|
||||||
fileType: file.type,
|
// fileType: file.type,
|
||||||
fileSize: file.size,
|
// fileSize: file.size,
|
||||||
acceptTypesCount: acceptTypes.length
|
// acceptTypesCount: acceptTypes.length
|
||||||
});
|
// });
|
||||||
|
|
||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
}
|
}
|
||||||
|
|
@ -280,27 +280,27 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const duplicates: string[] = [];
|
const duplicates: string[] = [];
|
||||||
const uniqueFiles: File[] = [];
|
const uniqueFiles: File[] = [];
|
||||||
|
|
||||||
console.log("🔍 중복 파일 체크:", {
|
// console.log("🔍 중복 파일 체크:", {
|
||||||
uploadedFiles: existingFiles.length,
|
// uploadedFiles: existingFiles.length,
|
||||||
existingFileNames: existingFileNames,
|
// existingFileNames: existingFileNames,
|
||||||
newFiles: validFiles.map(f => f.name.toLowerCase())
|
// newFiles: validFiles.map(f => f.name.toLowerCase())
|
||||||
});
|
// });
|
||||||
|
|
||||||
validFiles.forEach(file => {
|
validFiles.forEach(file => {
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
if (existingFileNames.includes(fileName)) {
|
if (existingFileNames.includes(fileName)) {
|
||||||
duplicates.push(file.name);
|
duplicates.push(file.name);
|
||||||
console.log("❌ 중복 파일 발견:", file.name);
|
// console.log("❌ 중복 파일 발견:", file.name);
|
||||||
} else {
|
} else {
|
||||||
uniqueFiles.push(file);
|
uniqueFiles.push(file);
|
||||||
console.log("✅ 새로운 파일:", file.name);
|
// console.log("✅ 새로운 파일:", file.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 중복 체크 결과:", {
|
// console.log("🔍 중복 체크 결과:", {
|
||||||
duplicates: duplicates,
|
// duplicates: duplicates,
|
||||||
uniqueFiles: uniqueFiles.map(f => f.name)
|
// uniqueFiles: uniqueFiles.map(f => f.name)
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
toast.error(`중복된 파일이 있습니다: ${duplicates.join(', ')}`, {
|
toast.error(`중복된 파일이 있습니다: ${duplicates.join(', ')}`, {
|
||||||
|
|
@ -319,11 +319,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : validFiles;
|
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : validFiles;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🔄 파일 업로드 시작:", {
|
// console.log("🔄 파일 업로드 시작:", {
|
||||||
originalFiles: validFiles.length,
|
// originalFiles: validFiles.length,
|
||||||
filesToUpload: filesToUpload.length,
|
// filesToUpload: filesToUpload.length,
|
||||||
uploading
|
// uploading
|
||||||
});
|
// });
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
||||||
|
|
||||||
|
|
@ -344,21 +344,21 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
// 3차: 기본값 설정
|
// 3차: 기본값 설정
|
||||||
if (!screenId) {
|
if (!screenId) {
|
||||||
screenId = 40; // 기본 화면 ID (디자인 모드용)
|
screenId = 40; // 기본 화면 ID (디자인 모드용)
|
||||||
console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
|
// console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentId = component.id;
|
const componentId = component.id;
|
||||||
const fieldName = component.columnName || component.id || 'file_attachment';
|
const fieldName = component.columnName || component.id || 'file_attachment';
|
||||||
|
|
||||||
console.log("📋 파일 업로드 기본 정보:", {
|
// console.log("📋 파일 업로드 기본 정보:", {
|
||||||
screenId,
|
// screenId,
|
||||||
screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
|
// screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
|
||||||
componentId,
|
// componentId,
|
||||||
fieldName,
|
// fieldName,
|
||||||
docType: localInputs.docType,
|
// docType: localInputs.docType,
|
||||||
docTypeName: localInputs.docTypeName,
|
// docTypeName: localInputs.docTypeName,
|
||||||
currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
|
// currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
|
||||||
});
|
// });
|
||||||
|
|
||||||
const response = await uploadFiles({
|
const response = await uploadFiles({
|
||||||
files: filesToUpload,
|
files: filesToUpload,
|
||||||
|
|
@ -372,11 +372,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
docTypeName: localInputs.docTypeName,
|
docTypeName: localInputs.docTypeName,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📤 파일 업로드 응답:", response);
|
// console.log("📤 파일 업로드 응답:", response);
|
||||||
|
|
||||||
if (response.success && (response.data || response.files)) {
|
if (response.success && (response.data || response.files)) {
|
||||||
const filesData = response.data || response.files;
|
const filesData = response.data || response.files;
|
||||||
console.log("📁 업로드된 파일 데이터:", filesData);
|
// console.log("📁 업로드된 파일 데이터:", filesData);
|
||||||
const newFiles: FileInfo[] = filesData.map((file: any) => ({
|
const newFiles: FileInfo[] = filesData.map((file: any) => ({
|
||||||
objid: file.objid || `temp_${Date.now()}_${Math.random()}`,
|
objid: file.objid || `temp_${Date.now()}_${Math.random()}`,
|
||||||
savedFileName: file.saved_file_name || file.savedFileName,
|
savedFileName: file.saved_file_name || file.savedFileName,
|
||||||
|
|
@ -431,10 +431,10 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
|
// console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
|
||||||
console.log("🔍 현재 컴포넌트 ID:", component.id);
|
// console.log("🔍 현재 컴포넌트 ID:", component.id);
|
||||||
console.log("🔍 업로드된 파일 수:", updatedFiles.length);
|
// console.log("🔍 업로드된 파일 수:", updatedFiles.length);
|
||||||
console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
|
// console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
|
||||||
|
|
||||||
const event = new CustomEvent('globalFileStateChanged', {
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: eventDetail
|
detail: eventDetail
|
||||||
|
|
@ -444,49 +444,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const listenerCount = window.getEventListeners ?
|
const listenerCount = window.getEventListeners ?
|
||||||
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
|
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
|
||||||
'unknown';
|
'unknown';
|
||||||
console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
|
// console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
|
||||||
|
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
|
// console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
|
||||||
|
|
||||||
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
|
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("🔄 추가 이벤트 발생 (지연 100ms)");
|
// console.log("🔄 추가 이벤트 발생 (지연 100ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true }
|
detail: { ...eventDetail, delayed: true }
|
||||||
}));
|
}));
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("🔄 추가 이벤트 발생 (지연 300ms)");
|
// console.log("🔄 추가 이벤트 발생 (지연 300ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
}));
|
}));
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("🔄 추가 이벤트 발생 (지연 500ms)");
|
// console.log("🔄 추가 이벤트 발생 (지연 500ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true, attempt: 3 }
|
detail: { ...eventDetail, delayed: true, attempt: 3 }
|
||||||
}));
|
}));
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// 직접 전역 상태 강제 업데이트
|
// 직접 전역 상태 강제 업데이트
|
||||||
console.log("🔄 전역 상태 강제 업데이트 시도");
|
// console.log("🔄 전역 상태 강제 업데이트 시도");
|
||||||
if ((window as any).forceRealtimePreviewUpdate) {
|
if ((window as any).forceRealtimePreviewUpdate) {
|
||||||
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
|
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
// console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
uploadedFiles: updatedFiles.length,
|
// uploadedFiles: updatedFiles.length,
|
||||||
status: "자동 영구 저장됨",
|
// status: "자동 영구 저장됨",
|
||||||
onUpdatePropertyExists: typeof onUpdateProperty === 'function',
|
// onUpdatePropertyExists: typeof onUpdateProperty === 'function',
|
||||||
globalFileStateUpdated: getGlobalFileState()[component.id]?.length || 0,
|
// globalFileStateUpdated: getGlobalFileState()[component.id]?.length || 0,
|
||||||
localStorageBackup: localStorage.getItem(`fileComponent_${component.id}_files`) ? 'saved' : 'not saved'
|
// localStorageBackup: localStorage.getItem(`fileComponent_${component.id}_files`) ? 'saved' : 'not saved'
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -505,40 +505,40 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.dispatchEvent(refreshEvent);
|
window.dispatchEvent(refreshEvent);
|
||||||
console.log("🔄 FileComponentConfigPanel 그리드 새로고침 이벤트 발생:", {
|
// console.log("🔄 FileComponentConfigPanel 그리드 새로고침 이벤트 발생:", {
|
||||||
tableName,
|
// tableName,
|
||||||
recordId,
|
// recordId,
|
||||||
columnName,
|
// columnName,
|
||||||
targetObjid,
|
// targetObjid,
|
||||||
fileCount: updatedFiles.length
|
// fileCount: updatedFiles.length
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
|
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
|
||||||
console.log("✅ 파일 업로드 성공:", {
|
// console.log("✅ 파일 업로드 성공:", {
|
||||||
newFilesCount: newFiles.length,
|
// newFilesCount: newFiles.length,
|
||||||
totalFiles: updatedFiles.length,
|
// totalFiles: updatedFiles.length,
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
// updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ 파일 업로드 오류:', {
|
// console.error('❌ 파일 업로드 오류:', {
|
||||||
error,
|
// error,
|
||||||
errorMessage: error?.message,
|
// errorMessage: error?.message,
|
||||||
errorResponse: error?.response?.data,
|
// errorResponse: error?.response?.data,
|
||||||
errorStatus: error?.response?.status,
|
// errorStatus: error?.response?.status,
|
||||||
componentId: component?.id,
|
// componentId: component?.id,
|
||||||
screenId,
|
// screenId,
|
||||||
fieldName
|
// fieldName
|
||||||
});
|
// });
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
|
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
|
||||||
} finally {
|
} finally {
|
||||||
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
// console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, [localInputs, localValues, uploadedFiles, onUpdateProperty, currentTableName, component, acceptTypes]);
|
}, [localInputs, localValues, uploadedFiles, onUpdateProperty, currentTableName, component, acceptTypes]);
|
||||||
|
|
@ -553,24 +553,24 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
});
|
});
|
||||||
toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`);
|
toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 다운로드 오류:', error);
|
// console.error('파일 다운로드 오류:', error);
|
||||||
toast.error('파일 다운로드에 실패했습니다.');
|
toast.error('파일 다운로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 파일 삭제 처리
|
// 파일 삭제 처리
|
||||||
const handleFileDelete = useCallback(async (fileId: string) => {
|
const handleFileDelete = useCallback(async (fileId: string) => {
|
||||||
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
|
// console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
|
||||||
fileId,
|
// fileId,
|
||||||
componentId: component?.id,
|
// componentId: component?.id,
|
||||||
currentFilesCount: uploadedFiles.length,
|
// currentFilesCount: uploadedFiles.length,
|
||||||
hasOnUpdateProperty: !!onUpdateProperty
|
// hasOnUpdateProperty: !!onUpdateProperty
|
||||||
});
|
// });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("📡 deleteFile API 호출 시작...");
|
// console.log("📡 deleteFile API 호출 시작...");
|
||||||
await deleteFile(fileId, 'temp_record');
|
await deleteFile(fileId, 'temp_record');
|
||||||
console.log("✅ deleteFile API 호출 성공");
|
// console.log("✅ deleteFile API 호출 성공");
|
||||||
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
||||||
setUploadedFiles(updatedFiles);
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
|
@ -591,12 +591,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||||
localStorage.setItem(tempBackupKey, JSON.stringify(updatedFiles));
|
localStorage.setItem(tempBackupKey, JSON.stringify(updatedFiles));
|
||||||
|
|
||||||
console.log("🗑️ FileComponentConfigPanel 파일 삭제:", {
|
// console.log("🗑️ FileComponentConfigPanel 파일 삭제:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
deletedFileId: fileId,
|
// deletedFileId: fileId,
|
||||||
remainingFiles: updatedFiles.length,
|
// remainingFiles: updatedFiles.length,
|
||||||
timestamp: timestamp
|
// timestamp: timestamp
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
|
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -610,39 +610,39 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
|
// console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
|
||||||
|
|
||||||
const event = new CustomEvent('globalFileStateChanged', {
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: eventDetail
|
detail: eventDetail
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
|
// console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
|
||||||
|
|
||||||
// 추가 지연 이벤트들
|
// 추가 지연 이벤트들
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
|
// console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true }
|
detail: { ...eventDetail, delayed: true }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
// console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
|
// console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
// console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileComponentConfigPanel 이벤트 발생 실패:", error);
|
// console.warn("FileComponentConfigPanel 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트도 유지
|
// 그리드 파일 상태 새로고침 이벤트도 유지
|
||||||
|
|
@ -663,20 +663,20 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
});
|
});
|
||||||
window.dispatchEvent(refreshEvent);
|
window.dispatchEvent(refreshEvent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileComponentConfigPanel refreshFileStatus 이벤트 발생 실패:", error);
|
// console.warn("FileComponentConfigPanel refreshFileStatus 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
|
// console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
|
||||||
tableName,
|
// tableName,
|
||||||
recordId,
|
// recordId,
|
||||||
columnName,
|
// columnName,
|
||||||
targetObjid,
|
// targetObjid,
|
||||||
fileCount: updatedFiles.length
|
// fileCount: updatedFiles.length
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('파일이 삭제되었습니다.');
|
toast.success('파일이 삭제되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 삭제 오류:', error);
|
// console.error('파일 삭제 오류:', error);
|
||||||
toast.error('파일 삭제에 실패했습니다.');
|
toast.error('파일 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, [uploadedFiles, onUpdateProperty, component.id]);
|
}, [uploadedFiles, onUpdateProperty, component.id]);
|
||||||
|
|
@ -703,16 +703,16 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
||||||
localStorage.removeItem(tempBackupKey);
|
localStorage.removeItem(tempBackupKey);
|
||||||
|
|
||||||
console.log("💾 파일 저장 완료:", {
|
// console.log("💾 파일 저장 완료:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
fileCount: uploadedFiles.length,
|
// fileCount: uploadedFiles.length,
|
||||||
timestamp: timestamp,
|
// timestamp: timestamp,
|
||||||
files: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
// files: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||||
});
|
// });
|
||||||
|
|
||||||
toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`);
|
toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 저장 오류:', error);
|
// console.error('파일 저장 오류:', error);
|
||||||
toast.error('파일 저장에 실패했습니다.');
|
toast.error('파일 저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]);
|
}, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]);
|
||||||
|
|
@ -732,21 +732,21 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const files = e.dataTransfer.files;
|
const files = e.dataTransfer.files;
|
||||||
console.log("📂 드래그앤드롭 이벤트:", {
|
// console.log("📂 드래그앤드롭 이벤트:", {
|
||||||
filesCount: files.length,
|
// filesCount: files.length,
|
||||||
files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
|
// files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
|
||||||
componentId: component?.id
|
// componentId: component?.id
|
||||||
});
|
// });
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
}, [handleFileUpload, component?.id]);
|
}, [handleFileUpload, component?.id]);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
console.log("📁 파일 선택 이벤트:", {
|
// console.log("📁 파일 선택 이벤트:", {
|
||||||
filesCount: e.target.files?.length || 0,
|
// filesCount: e.target.files?.length || 0,
|
||||||
files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
|
// files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
|
||||||
});
|
// });
|
||||||
|
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
|
@ -782,27 +782,27 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
if (prevComponentIdRef.current !== component.id) {
|
if (prevComponentIdRef.current !== component.id) {
|
||||||
// 새로운 컴포넌트로 변경된 경우
|
// 새로운 컴포넌트로 변경된 경우
|
||||||
console.log("🔄 FileComponentConfigPanel 새 컴포넌트 선택:", {
|
// console.log("🔄 FileComponentConfigPanel 새 컴포넌트 선택:", {
|
||||||
prevComponentId: prevComponentIdRef.current,
|
// prevComponentId: prevComponentIdRef.current,
|
||||||
newComponentId: component.id,
|
// newComponentId: component.id,
|
||||||
componentFiles: componentFiles.length,
|
// componentFiles: componentFiles.length,
|
||||||
action: "새 컴포넌트 → 상태 초기화",
|
// action: "새 컴포넌트 → 상태 초기화",
|
||||||
globalFileStateExists: !!getGlobalFileState()[component.id],
|
// globalFileStateExists: !!getGlobalFileState()[component.id],
|
||||||
globalFileStateLength: getGlobalFileState()[component.id]?.length || 0,
|
// globalFileStateLength: getGlobalFileState()[component.id]?.length || 0,
|
||||||
localStorageExists: !!localStorage.getItem(`fileComponent_${component.id}_files`),
|
// localStorageExists: !!localStorage.getItem(`fileComponent_${component.id}_files`),
|
||||||
onUpdatePropertyExists: typeof onUpdateProperty === 'function'
|
// onUpdatePropertyExists: typeof onUpdateProperty === 'function'
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 1순위: 전역 상태에서 파일 복원
|
// 1순위: 전역 상태에서 파일 복원
|
||||||
const globalFileState = getGlobalFileState();
|
const globalFileState = getGlobalFileState();
|
||||||
const globalFiles = globalFileState[component.id];
|
const globalFiles = globalFileState[component.id];
|
||||||
|
|
||||||
if (globalFiles && globalFiles.length > 0) {
|
if (globalFiles && globalFiles.length > 0) {
|
||||||
console.log("🌐 전역 상태에서 파일 복원:", {
|
// console.log("🌐 전역 상태에서 파일 복원:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
globalFiles: globalFiles.length,
|
// globalFiles: globalFiles.length,
|
||||||
action: "전역 상태 → 상태 복원"
|
// action: "전역 상태 → 상태 복원"
|
||||||
});
|
// });
|
||||||
setUploadedFiles(globalFiles);
|
setUploadedFiles(globalFiles);
|
||||||
onUpdateProperty(component.id, "uploadedFiles", globalFiles);
|
onUpdateProperty(component.id, "uploadedFiles", globalFiles);
|
||||||
}
|
}
|
||||||
|
|
@ -814,11 +814,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
if (backupFiles && componentFiles.length === 0) {
|
if (backupFiles && componentFiles.length === 0) {
|
||||||
try {
|
try {
|
||||||
const parsedBackupFiles = JSON.parse(backupFiles);
|
const parsedBackupFiles = JSON.parse(backupFiles);
|
||||||
console.log("📂 localStorage에서 파일 복원:", {
|
// console.log("📂 localStorage에서 파일 복원:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
backupFiles: parsedBackupFiles.length,
|
// backupFiles: parsedBackupFiles.length,
|
||||||
action: "백업 → 상태 복원"
|
// action: "백업 → 상태 복원"
|
||||||
});
|
// });
|
||||||
setUploadedFiles(parsedBackupFiles);
|
setUploadedFiles(parsedBackupFiles);
|
||||||
// 전역 상태에도 저장
|
// 전역 상태에도 저장
|
||||||
setGlobalFileState(prev => ({
|
setGlobalFileState(prev => ({
|
||||||
|
|
@ -828,7 +828,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
// 컴포넌트 속성에도 복원
|
// 컴포넌트 속성에도 복원
|
||||||
onUpdateProperty(component.id, "uploadedFiles", parsedBackupFiles);
|
onUpdateProperty(component.id, "uploadedFiles", parsedBackupFiles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("백업 파일 복원 실패:", error);
|
// console.error("백업 파일 복원 실패:", error);
|
||||||
setUploadedFiles(componentFiles);
|
setUploadedFiles(componentFiles);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -839,12 +839,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
prevComponentIdRef.current = component.id;
|
prevComponentIdRef.current = component.id;
|
||||||
} else if (componentFiles.length > 0 && JSON.stringify(componentFiles) !== JSON.stringify(uploadedFiles)) {
|
} else if (componentFiles.length > 0 && JSON.stringify(componentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
// 같은 컴포넌트에서 파일이 업데이트된 경우
|
// 같은 컴포넌트에서 파일이 업데이트된 경우
|
||||||
console.log("🔄 FileComponentConfigPanel 파일 동기화:", {
|
// console.log("🔄 FileComponentConfigPanel 파일 동기화:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
componentFiles: componentFiles.length,
|
// componentFiles: componentFiles.length,
|
||||||
currentFiles: uploadedFiles.length,
|
// currentFiles: uploadedFiles.length,
|
||||||
action: "컴포넌트 → 상태 동기화"
|
// action: "컴포넌트 → 상태 동기화"
|
||||||
});
|
// });
|
||||||
setUploadedFiles(componentFiles);
|
setUploadedFiles(componentFiles);
|
||||||
}
|
}
|
||||||
}, [component.id]); // 컴포넌트 ID가 변경될 때만 초기화
|
}, [component.id]); // 컴포넌트 ID가 변경될 때만 초기화
|
||||||
|
|
@ -855,24 +855,24 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const { componentId, files, fileCount, isRestore, source } = event.detail;
|
const { componentId, files, fileCount, isRestore, source } = event.detail;
|
||||||
|
|
||||||
if (componentId === component.id) {
|
if (componentId === component.id) {
|
||||||
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
// console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
||||||
componentId,
|
// componentId,
|
||||||
fileCount,
|
// fileCount,
|
||||||
isRestore: !!isRestore,
|
// isRestore: !!isRestore,
|
||||||
source: source || 'unknown',
|
// source: source || 'unknown',
|
||||||
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
// files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (files && Array.isArray(files)) {
|
if (files && Array.isArray(files)) {
|
||||||
setUploadedFiles(files);
|
setUploadedFiles(files);
|
||||||
|
|
||||||
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
|
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
|
||||||
if (isRestore || source === 'realScreen') {
|
if (isRestore || source === 'realScreen') {
|
||||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
|
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
|
||||||
componentId,
|
// componentId,
|
||||||
fileCount: files.length,
|
// fileCount: files.length,
|
||||||
source: source || 'restore'
|
// source: source || 'restore'
|
||||||
});
|
// });
|
||||||
|
|
||||||
onUpdateProperty(component.id, "uploadedFiles", files);
|
onUpdateProperty(component.id, "uploadedFiles", files);
|
||||||
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
||||||
|
|
@ -881,12 +881,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
try {
|
try {
|
||||||
const backupKey = `fileComponent_${component.id}_files`;
|
const backupKey = `fileComponent_${component.id}_files`;
|
||||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||||
console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
|
// console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
fileCount: files.length
|
// fileCount: files.length
|
||||||
});
|
// });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
// console.warn("localStorage 백업 업데이트 실패:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전역 상태 업데이트
|
// 전역 상태 업데이트
|
||||||
|
|
@ -895,10 +895,10 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
[component.id]: files
|
[component.id]: files
|
||||||
}));
|
}));
|
||||||
} else if (isRestore) {
|
} else if (isRestore) {
|
||||||
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
// console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
||||||
componentId,
|
// componentId,
|
||||||
restoredFileCount: files.length
|
// restoredFileCount: files.length
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1119,18 +1119,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
// console.log("🖱️ 파일 업로드 영역 클릭:", {
|
||||||
uploading,
|
// uploading,
|
||||||
inputElement: document.getElementById('file-input-config'),
|
// inputElement: document.getElementById('file-input-config'),
|
||||||
componentId: component?.id
|
// componentId: component?.id
|
||||||
});
|
// });
|
||||||
if (!uploading) {
|
if (!uploading) {
|
||||||
const input = document.getElementById('file-input-config');
|
const input = document.getElementById('file-input-config');
|
||||||
if (input) {
|
if (input) {
|
||||||
console.log("✅ 파일 input 클릭 실행");
|
// console.log("✅ 파일 input 클릭 실행");
|
||||||
input.click();
|
input.click();
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ 파일 input 요소를 찾을 수 없음");
|
// console.log("❌ 파일 input 요소를 찾을 수 없음");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,14 @@ export default function LayoutsPanel({
|
||||||
height: 400, // 높이는 고정
|
height: 400, // 높이는 고정
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🎯 카드 레이아웃 동적 크기 계산:", {
|
// console.log("🎯 카드 레이아웃 동적 크기 계산:", {
|
||||||
gridColumns: 8,
|
// gridColumns: 8,
|
||||||
screenResolution,
|
// screenResolution,
|
||||||
gridSettings,
|
// gridSettings,
|
||||||
gridInfo,
|
// gridInfo,
|
||||||
calculatedWidth,
|
// calculatedWidth,
|
||||||
finalSize: calculatedSize,
|
// finalSize: calculatedSize,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 레이아웃 컴포넌트 데이터 생성
|
// 새 레이아웃 컴포넌트 데이터 생성
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ const DataTableConfigPanelWrapper: React.FC<{
|
||||||
// 안정화된 업데이트 핸들러
|
// 안정화된 업데이트 핸들러
|
||||||
const handleUpdateComponent = React.useCallback(
|
const handleUpdateComponent = React.useCallback(
|
||||||
(updates: Partial<DataTableComponent>) => {
|
(updates: Partial<DataTableComponent>) => {
|
||||||
console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
|
// console.log("🔄 DataTable 래퍼 컴포넌트 업데이트:", updates);
|
||||||
|
|
||||||
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
|
// 변경사항이 있는지 확인 (간단한 비교로 성능 향상)
|
||||||
const hasChanges = Object.entries(updates).some(([key, value]) => {
|
const hasChanges = Object.entries(updates).some(([key, value]) => {
|
||||||
|
|
@ -48,7 +48,7 @@ const DataTableConfigPanelWrapper: React.FC<{
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasChanges) {
|
if (!hasChanges) {
|
||||||
console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
|
// console.log("⏭️ 래퍼: 변경사항 없음, 업데이트 스킵");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,18 +123,18 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
canUngroup = false,
|
canUngroup = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인
|
// 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인
|
||||||
console.log("📍 PropertiesPanel 렌더링:", {
|
// console.log("📍 PropertiesPanel 렌더링:", {
|
||||||
renderTime: Date.now(),
|
// renderTime: Date.now(),
|
||||||
selectedComponentId: selectedComponent?.id,
|
// selectedComponentId: selectedComponent?.id,
|
||||||
dragState: dragState
|
// dragState: dragState
|
||||||
? {
|
// ? {
|
||||||
isDragging: dragState.isDragging,
|
// isDragging: dragState.isDragging,
|
||||||
draggedComponentId: dragState.draggedComponent?.id,
|
// draggedComponentId: dragState.draggedComponent?.id,
|
||||||
currentPosition: dragState.currentPosition,
|
// currentPosition: dragState.currentPosition,
|
||||||
dragStateRef: dragState, // 객체 참조 확인
|
// dragStateRef: dragState, // 객체 참조 확인
|
||||||
}
|
// }
|
||||||
: "null",
|
// : "null",
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
|
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
|
||||||
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
|
const { webTypes, isLoading: isWebTypesLoading } = useWebTypes({ active: "Y" });
|
||||||
|
|
@ -160,11 +160,11 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
// 실시간 위치 계산 (드래그 중일 때는 dragState.currentPosition 사용)
|
// 실시간 위치 계산 (드래그 중일 때는 dragState.currentPosition 사용)
|
||||||
const getCurrentPosition = () => {
|
const getCurrentPosition = () => {
|
||||||
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
||||||
console.log("🎯 드래그 중 실시간 위치:", {
|
// console.log("🎯 드래그 중 실시간 위치:", {
|
||||||
draggedId: dragState.draggedComponent?.id,
|
// draggedId: dragState.draggedComponent?.id,
|
||||||
selectedId: selectedComponent?.id,
|
// selectedId: selectedComponent?.id,
|
||||||
currentPosition: dragState.currentPosition,
|
// currentPosition: dragState.currentPosition,
|
||||||
});
|
// });
|
||||||
return {
|
return {
|
||||||
x: Math.round(dragState.currentPosition.x),
|
x: Math.round(dragState.currentPosition.x),
|
||||||
y: Math.round(dragState.currentPosition.y),
|
y: Math.round(dragState.currentPosition.y),
|
||||||
|
|
@ -207,7 +207,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
||||||
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
|
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
|
||||||
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
|
||||||
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
|
labelDisplay: selectedComponent?.style?.labelDisplay ?? true,
|
||||||
// widgetType도 로컬 상태로 관리
|
// widgetType도 로컬 상태로 관리
|
||||||
widgetType:
|
widgetType:
|
||||||
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
(selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).widgetType : "text") || "text",
|
||||||
|
|
@ -225,22 +225,22 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
|
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
|
||||||
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
|
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
|
||||||
|
|
||||||
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
// console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
||||||
componentId: selectedComponent.id,
|
// componentId: selectedComponent.id,
|
||||||
componentType: selectedComponent.type,
|
// componentType: selectedComponent.type,
|
||||||
isDragging: dragState?.isDragging,
|
// isDragging: dragState?.isDragging,
|
||||||
justFinishedDrag: dragState?.justFinishedDrag,
|
// justFinishedDrag: dragState?.justFinishedDrag,
|
||||||
currentValues: {
|
// currentValues: {
|
||||||
placeholder: widget?.placeholder,
|
// placeholder: widget?.placeholder,
|
||||||
title: group?.title || area?.title,
|
// title: group?.title || area?.title,
|
||||||
description: area?.description,
|
// description: area?.description,
|
||||||
actualPositionX: selectedComponent.position.x,
|
// actualPositionX: selectedComponent.position.x,
|
||||||
actualPositionY: selectedComponent.position.y,
|
// actualPositionY: selectedComponent.position.y,
|
||||||
dragPositionX: dragState?.currentPosition.x,
|
// dragPositionX: dragState?.currentPosition.x,
|
||||||
dragPositionY: dragState?.currentPosition.y,
|
// dragPositionY: dragState?.currentPosition.y,
|
||||||
},
|
// },
|
||||||
getCurrentPosResult: getCurrentPosition(),
|
// getCurrentPosResult: getCurrentPosition(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영)
|
// 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영)
|
||||||
if (!dragState?.isDragging || dragState.draggedComponent?.id !== selectedComponent.id) {
|
if (!dragState?.isDragging || dragState.draggedComponent?.id !== selectedComponent.id) {
|
||||||
|
|
@ -265,15 +265,15 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
|
||||||
required: widget?.required || false,
|
required: widget?.required || false,
|
||||||
readonly: widget?.readonly || false,
|
readonly: widget?.readonly || false,
|
||||||
labelDisplay: selectedComponent.style?.labelDisplay !== false,
|
labelDisplay: selectedComponent.style?.labelDisplay ?? true,
|
||||||
// widgetType 동기화
|
// widgetType 동기화
|
||||||
widgetType: widget?.widgetType || "text",
|
widgetType: widget?.widgetType || "text",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ localInputs 업데이트 완료:", {
|
// console.log("✅ localInputs 업데이트 완료:", {
|
||||||
positionX: currentPos.x.toString(),
|
// positionX: currentPos.x.toString(),
|
||||||
positionY: currentPos.y.toString(),
|
// positionY: currentPos.y.toString(),
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -307,24 +307,24 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🔍 디버깅: 컴포넌트 구조 확인
|
// 🔍 디버깅: 컴포넌트 구조 확인
|
||||||
console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
|
// console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
|
||||||
componentType: selectedComponent.type,
|
// componentType: selectedComponent.type,
|
||||||
componentId: selectedComponent.id,
|
// componentId: selectedComponent.id,
|
||||||
componentConfig: selectedComponent.componentConfig,
|
// componentConfig: selectedComponent.componentConfig,
|
||||||
config: selectedComponent.config,
|
// config: selectedComponent.config,
|
||||||
webTypeConfig: selectedComponent.webTypeConfig,
|
// webTypeConfig: selectedComponent.webTypeConfig,
|
||||||
actionType1: selectedComponent.componentConfig?.action?.type,
|
// actionType1: selectedComponent.componentConfig?.action?.type,
|
||||||
actionType2: selectedComponent.config?.action?.type,
|
// actionType2: selectedComponent.config?.action?.type,
|
||||||
actionType3: selectedComponent.webTypeConfig?.actionType,
|
// actionType3: selectedComponent.webTypeConfig?.actionType,
|
||||||
isDeleteAction: isDeleteAction(),
|
// isDeleteAction: isDeleteAction(),
|
||||||
currentLabelColor: selectedComponent.style?.labelColor,
|
// currentLabelColor: selectedComponent.style?.labelColor,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 액션에 따른 라벨 색상 자동 설정
|
// 액션에 따른 라벨 색상 자동 설정
|
||||||
if (isDeleteAction()) {
|
if (isDeleteAction()) {
|
||||||
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
|
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
|
||||||
if (selectedComponent.style?.labelColor !== '#ef4444') {
|
if (selectedComponent.style?.labelColor !== '#ef4444') {
|
||||||
console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
|
// console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
|
||||||
onUpdateProperty("style", {
|
onUpdateProperty("style", {
|
||||||
...selectedComponent.style,
|
...selectedComponent.style,
|
||||||
labelColor: '#ef4444'
|
labelColor: '#ef4444'
|
||||||
|
|
@ -339,7 +339,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
} else {
|
} else {
|
||||||
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
|
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
|
||||||
if (selectedComponent.style?.labelColor === '#ef4444') {
|
if (selectedComponent.style?.labelColor === '#ef4444') {
|
||||||
console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
|
// console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
|
||||||
onUpdateProperty("style", {
|
onUpdateProperty("style", {
|
||||||
...selectedComponent.style,
|
...selectedComponent.style,
|
||||||
labelColor: '#3b83f6'
|
labelColor: '#3b83f6'
|
||||||
|
|
@ -365,12 +365,12 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
|
|
||||||
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
|
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
|
||||||
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
||||||
console.log("🎯 렌더링 중 드래그 상태 감지:", {
|
// console.log("🎯 렌더링 중 드래그 상태 감지:", {
|
||||||
isDragging: dragState.isDragging,
|
// isDragging: dragState.isDragging,
|
||||||
draggedId: dragState.draggedComponent?.id,
|
// draggedId: dragState.draggedComponent?.id,
|
||||||
selectedId: selectedComponent?.id,
|
// selectedId: selectedComponent?.id,
|
||||||
currentPosition: dragState.currentPosition,
|
// currentPosition: dragState.currentPosition,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const newPosition = {
|
const newPosition = {
|
||||||
x: dragState.currentPosition.x,
|
x: dragState.currentPosition.x,
|
||||||
|
|
@ -379,10 +379,10 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
|
|
||||||
// 위치가 변경되었는지 확인
|
// 위치가 변경되었는지 확인
|
||||||
if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) {
|
if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) {
|
||||||
console.log("🔄 위치 변경 감지됨:", {
|
// console.log("🔄 위치 변경 감지됨:", {
|
||||||
oldPosition: lastDragPosition,
|
// oldPosition: lastDragPosition,
|
||||||
newPosition: newPosition,
|
// newPosition: newPosition,
|
||||||
});
|
// });
|
||||||
// 다음 렌더링 사이클에서 업데이트
|
// 다음 렌더링 사이클에서 업데이트
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLastDragPosition(newPosition);
|
setLastDragPosition(newPosition);
|
||||||
|
|
@ -542,7 +542,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
value={localInputs.placeholder}
|
value={localInputs.placeholder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
console.log("🔄 placeholder 변경:", newValue);
|
// console.log("🔄 placeholder 변경:", newValue);
|
||||||
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
|
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
|
||||||
onUpdateProperty("placeholder", newValue);
|
onUpdateProperty("placeholder", newValue);
|
||||||
}}
|
}}
|
||||||
|
|
@ -610,7 +610,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
const realTimeX = Math.round(dragState.currentPosition.x);
|
const realTimeX = Math.round(dragState.currentPosition.x);
|
||||||
console.log("🔥 실시간 X 렌더링:", realTimeX, "forceRender:", forceRender);
|
// console.log("🔥 실시간 X 렌더링:", realTimeX, "forceRender:", forceRender);
|
||||||
return realTimeX.toString();
|
return realTimeX.toString();
|
||||||
}
|
}
|
||||||
return localInputs.positionX;
|
return localInputs.positionX;
|
||||||
|
|
@ -640,7 +640,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
const isDragging = dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id;
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
const realTimeY = Math.round(dragState.currentPosition.y);
|
const realTimeY = Math.round(dragState.currentPosition.y);
|
||||||
console.log("🔥 실시간 Y 렌더링:", realTimeY, "forceRender:", forceRender);
|
// console.log("🔥 실시간 Y 렌더링:", realTimeY, "forceRender:", forceRender);
|
||||||
return realTimeY.toString();
|
return realTimeY.toString();
|
||||||
}
|
}
|
||||||
return localInputs.positionY;
|
return localInputs.positionY;
|
||||||
|
|
@ -770,7 +770,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
id="labelDisplay"
|
id="labelDisplay"
|
||||||
checked={localInputs.labelDisplay}
|
checked={localInputs.labelDisplay}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log("🔄 라벨 표시 변경:", e.target.checked);
|
// console.log("🔄 라벨 표시 변경:", e.target.checked);
|
||||||
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
|
setLocalInputs((prev) => ({ ...prev, labelDisplay: e.target.checked }));
|
||||||
onUpdateProperty("style.labelDisplay", e.target.checked);
|
onUpdateProperty("style.labelDisplay", e.target.checked);
|
||||||
}}
|
}}
|
||||||
|
|
@ -788,7 +788,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||||
value={localInputs.labelText}
|
value={localInputs.labelText}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
console.log("🔄 라벨 텍스트 변경:", newValue);
|
// console.log("🔄 라벨 텍스트 변경:", newValue);
|
||||||
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
|
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
|
||||||
// 기본 라벨과 스타일 라벨을 모두 업데이트
|
// 기본 라벨과 스타일 라벨을 모두 업데이트
|
||||||
onUpdateProperty("label", newValue);
|
onUpdateProperty("label", newValue);
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,7 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
const dynamicTemplates = React.useMemo(() => {
|
const dynamicTemplates = React.useMemo(() => {
|
||||||
if (error || !dbTemplates) {
|
if (error || !dbTemplates) {
|
||||||
// 오류 발생 시 폴백 템플릿 사용
|
// 오류 발생 시 폴백 템플릿 사용
|
||||||
console.warn("템플릿 로딩 실패, 폴백 템플릿 사용:", error);
|
// console.warn("템플릿 로딩 실패, 폴백 템플릿 사용:", error);
|
||||||
return fallbackTemplates;
|
return fallbackTemplates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +573,7 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
e.currentTarget.style.opacity = '1';
|
e.currentTarget.style.opacity = '1';
|
||||||
e.currentTarget.style.transform = 'none';
|
e.currentTarget.style.transform = 'none';
|
||||||
}}
|
}}
|
||||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-6 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,13 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||||
console.log("☑️ CheckboxTypeConfig 업데이트:", {
|
// console.log("☑️ CheckboxTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
localValues,
|
// localValues,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,12 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
||||||
|
|
||||||
// 실제 config 업데이트
|
// 실제 config 업데이트
|
||||||
const newConfig = { ...safeConfig, [key]: value };
|
const newConfig = { ...safeConfig, [key]: value };
|
||||||
console.log("💻 CodeTypeConfig 업데이트:", {
|
// console.log("💻 CodeTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
});
|
// });
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
|
|
||||||
// 로컬 상태로 실시간 입력 관리
|
// 로컬 상태로 실시간 입력 관리
|
||||||
const [localValues, setLocalValues] = useState(() => {
|
const [localValues, setLocalValues] = useState(() => {
|
||||||
console.log("📅 DateTypeConfigPanel 초기 상태 설정:", {
|
// console.log("📅 DateTypeConfigPanel 초기 상태 설정:", {
|
||||||
config,
|
// config,
|
||||||
safeConfig,
|
// safeConfig,
|
||||||
});
|
// });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
format: safeConfig.format,
|
format: safeConfig.format,
|
||||||
|
|
@ -46,23 +46,23 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
// config가 실제로 존재하고 의미있는 데이터가 있을 때만 업데이트
|
// config가 실제로 존재하고 의미있는 데이터가 있을 때만 업데이트
|
||||||
const hasValidConfig = config && Object.keys(config).length > 0;
|
const hasValidConfig = config && Object.keys(config).length > 0;
|
||||||
|
|
||||||
console.log("📅 DateTypeConfigPanel config 변경 감지:", {
|
// console.log("📅 DateTypeConfigPanel config 변경 감지:", {
|
||||||
config,
|
// config,
|
||||||
configExists: !!config,
|
// configExists: !!config,
|
||||||
configKeys: config ? Object.keys(config) : [],
|
// configKeys: config ? Object.keys(config) : [],
|
||||||
hasValidConfig,
|
// hasValidConfig,
|
||||||
safeConfig,
|
// safeConfig,
|
||||||
safeConfigKeys: Object.keys(safeConfig),
|
// safeConfigKeys: Object.keys(safeConfig),
|
||||||
currentLocalValues: localValues,
|
// currentLocalValues: localValues,
|
||||||
configStringified: JSON.stringify(config),
|
// configStringified: JSON.stringify(config),
|
||||||
safeConfigStringified: JSON.stringify(safeConfig),
|
// safeConfigStringified: JSON.stringify(safeConfig),
|
||||||
willUpdateLocalValues: hasValidConfig,
|
// willUpdateLocalValues: hasValidConfig,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// config가 없거나 비어있으면 로컬 상태를 유지
|
// config가 없거나 비어있으면 로컬 상태를 유지
|
||||||
if (!hasValidConfig) {
|
if (!hasValidConfig) {
|
||||||
console.log("⚠️ config가 없거나 비어있음 - 로컬 상태 유지");
|
// console.log("⚠️ config가 없거나 비어있음 - 로컬 상태 유지");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,25 +84,25 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
localValues.minDate !== newLocalValues.minDate ||
|
localValues.minDate !== newLocalValues.minDate ||
|
||||||
localValues.maxDate !== newLocalValues.maxDate;
|
localValues.maxDate !== newLocalValues.maxDate;
|
||||||
|
|
||||||
console.log("🔄 로컬 상태 업데이트 검사:", {
|
// console.log("🔄 로컬 상태 업데이트 검사:", {
|
||||||
oldLocalValues: localValues,
|
// oldLocalValues: localValues,
|
||||||
newLocalValues,
|
// newLocalValues,
|
||||||
hasChanges,
|
// hasChanges,
|
||||||
changes: {
|
// changes: {
|
||||||
format: localValues.format !== newLocalValues.format,
|
// format: localValues.format !== newLocalValues.format,
|
||||||
showTime: localValues.showTime !== newLocalValues.showTime,
|
// showTime: localValues.showTime !== newLocalValues.showTime,
|
||||||
defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
|
// defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
|
||||||
placeholder: localValues.placeholder !== newLocalValues.placeholder,
|
// placeholder: localValues.placeholder !== newLocalValues.placeholder,
|
||||||
minDate: localValues.minDate !== newLocalValues.minDate,
|
// minDate: localValues.minDate !== newLocalValues.minDate,
|
||||||
maxDate: localValues.maxDate !== newLocalValues.maxDate,
|
// maxDate: localValues.maxDate !== newLocalValues.maxDate,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
console.log("✅ 로컬 상태 업데이트 실행");
|
// console.log("✅ 로컬 상태 업데이트 실행");
|
||||||
setLocalValues(newLocalValues);
|
setLocalValues(newLocalValues);
|
||||||
} else {
|
} else {
|
||||||
console.log("⏭️ 변경사항 없음 - 로컬 상태 유지");
|
// console.log("⏭️ 변경사항 없음 - 로컬 상태 유지");
|
||||||
}
|
}
|
||||||
}, [JSON.stringify(config)]);
|
}, [JSON.stringify(config)]);
|
||||||
|
|
||||||
|
|
@ -112,36 +112,36 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
|
|
||||||
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
|
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
|
||||||
const newConfig = JSON.parse(JSON.stringify({ ...localValues, [key]: value }));
|
const newConfig = JSON.parse(JSON.stringify({ ...localValues, [key]: value }));
|
||||||
console.log("📅 DateTypeConfig 업데이트:", {
|
// console.log("📅 DateTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
localValues,
|
// localValues,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
changes: {
|
// changes: {
|
||||||
format: newConfig.format !== safeConfig.format,
|
// format: newConfig.format !== safeConfig.format,
|
||||||
showTime: newConfig.showTime !== safeConfig.showTime,
|
// showTime: newConfig.showTime !== safeConfig.showTime,
|
||||||
placeholder: newConfig.placeholder !== safeConfig.placeholder,
|
// placeholder: newConfig.placeholder !== safeConfig.placeholder,
|
||||||
minDate: newConfig.minDate !== safeConfig.minDate,
|
// minDate: newConfig.minDate !== safeConfig.minDate,
|
||||||
maxDate: newConfig.maxDate !== safeConfig.maxDate,
|
// maxDate: newConfig.maxDate !== safeConfig.maxDate,
|
||||||
defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
|
// defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
|
||||||
},
|
// },
|
||||||
willCallOnConfigChange: true,
|
// willCallOnConfigChange: true,
|
||||||
});
|
// });
|
||||||
|
|
||||||
console.log("🔄 onConfigChange 호출 직전:", {
|
// console.log("🔄 onConfigChange 호출 직전:", {
|
||||||
newConfig,
|
// newConfig,
|
||||||
configStringified: JSON.stringify(newConfig),
|
// configStringified: JSON.stringify(newConfig),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("✅ onConfigChange 호출 완료:", {
|
// console.log("✅ onConfigChange 호출 완료:", {
|
||||||
key,
|
// key,
|
||||||
newConfig,
|
// newConfig,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
@ -156,11 +156,11 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
<Select
|
<Select
|
||||||
value={localValues.format}
|
value={localValues.format}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
console.log("📅 날짜 형식 변경:", {
|
// console.log("📅 날짜 형식 변경:", {
|
||||||
oldFormat: localValues.format,
|
// oldFormat: localValues.format,
|
||||||
newFormat: value,
|
// newFormat: value,
|
||||||
oldShowTime: localValues.showTime,
|
// oldShowTime: localValues.showTime,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// format 변경 시 showTime도 자동 동기화
|
// format 변경 시 showTime도 자동 동기화
|
||||||
const hasTime = value.includes("HH:mm");
|
const hasTime = value.includes("HH:mm");
|
||||||
|
|
@ -174,11 +174,11 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("🔄 format+showTime 동시 업데이트:", {
|
// console.log("🔄 format+showTime 동시 업데이트:", {
|
||||||
newFormat: value,
|
// newFormat: value,
|
||||||
newShowTime: hasTime,
|
// newShowTime: hasTime,
|
||||||
newConfig,
|
// newConfig,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태도 동시 업데이트
|
// 로컬 상태도 동시 업데이트
|
||||||
setLocalValues((prev) => ({
|
setLocalValues((prev) => ({
|
||||||
|
|
@ -214,11 +214,11 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
checked={localValues.showTime}
|
checked={localValues.showTime}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const newShowTime = !!checked;
|
const newShowTime = !!checked;
|
||||||
console.log("⏰ 시간 표시 체크박스 변경:", {
|
// console.log("⏰ 시간 표시 체크박스 변경:", {
|
||||||
oldShowTime: localValues.showTime,
|
// oldShowTime: localValues.showTime,
|
||||||
newShowTime,
|
// newShowTime,
|
||||||
currentFormat: localValues.format,
|
// currentFormat: localValues.format,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// showTime 변경 시 format도 적절히 조정
|
// showTime 변경 시 format도 적절히 조정
|
||||||
let newFormat = localValues.format;
|
let newFormat = localValues.format;
|
||||||
|
|
@ -230,11 +230,11 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
||||||
newFormat = "YYYY-MM-DD";
|
newFormat = "YYYY-MM-DD";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔄 showTime+format 동시 업데이트:", {
|
// console.log("🔄 showTime+format 동시 업데이트:", {
|
||||||
newShowTime,
|
// newShowTime,
|
||||||
oldFormat: localValues.format,
|
// oldFormat: localValues.format,
|
||||||
newFormat,
|
// newFormat,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
|
// 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
|
||||||
const newConfig = JSON.parse(
|
const newConfig = JSON.parse(
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,12 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
|
|
||||||
// 실제 config 업데이트
|
// 실제 config 업데이트
|
||||||
const newConfig = { ...safeConfig, [key]: value };
|
const newConfig = { ...safeConfig, [key]: value };
|
||||||
console.log("🏢 EntityTypeConfig 업데이트:", {
|
// console.log("🏢 EntityTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
});
|
// });
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,12 @@ export const FileTypeConfigPanel: React.FC<FileTypeConfigPanelProps> = ({ config
|
||||||
|
|
||||||
// 실제 config 업데이트
|
// 실제 config 업데이트
|
||||||
const newConfig = { ...safeConfig, [key]: value };
|
const newConfig = { ...safeConfig, [key]: value };
|
||||||
console.log("📁 FileTypeConfig 업데이트:", {
|
// console.log("📁 FileTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
});
|
// });
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,14 @@ export const NumberTypeConfigPanel: React.FC<NumberTypeConfigPanelProps> = ({ co
|
||||||
};
|
};
|
||||||
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||||
console.log("🔢 NumberTypeConfig 업데이트:", {
|
// console.log("🔢 NumberTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
localValues,
|
// localValues,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,14 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
|
|
||||||
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
|
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
|
||||||
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: processedValue }));
|
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: processedValue }));
|
||||||
console.log("📻 RadioTypeConfig 업데이트:", {
|
// console.log("📻 RadioTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
processedValue,
|
// processedValue,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -87,12 +87,12 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
const newOptionData = { ...newOption };
|
const newOptionData = { ...newOption };
|
||||||
const updatedOptions = [...(safeConfig.options || []), newOptionData];
|
const updatedOptions = [...(safeConfig.options || []), newOptionData];
|
||||||
|
|
||||||
console.log("➕ RadioType 옵션 추가:", {
|
// console.log("➕ RadioType 옵션 추가:", {
|
||||||
newOption: newOptionData,
|
// newOption: newOptionData,
|
||||||
updatedOptions,
|
// updatedOptions,
|
||||||
currentLocalOptions: localOptions,
|
// currentLocalOptions: localOptions,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalOptions((prev) => {
|
setLocalOptions((prev) => {
|
||||||
|
|
@ -103,7 +103,7 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
value: newOption.value,
|
value: newOption.value,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
console.log("📻 RadioType 로컬 옵션 업데이트:", newLocalOptions);
|
// console.log("📻 RadioType 로컬 옵션 업데이트:", newLocalOptions);
|
||||||
return newLocalOptions;
|
return newLocalOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -113,16 +113,16 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeOption = (index: number) => {
|
const removeOption = (index: number) => {
|
||||||
console.log("➖ RadioType 옵션 삭제:", {
|
// console.log("➖ RadioType 옵션 삭제:", {
|
||||||
removeIndex: index,
|
// removeIndex: index,
|
||||||
currentOptions: safeConfig.options,
|
// currentOptions: safeConfig.options,
|
||||||
currentLocalOptions: localOptions,
|
// currentLocalOptions: localOptions,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalOptions((prev) => {
|
setLocalOptions((prev) => {
|
||||||
const newLocalOptions = prev.filter((_, i) => i !== index);
|
const newLocalOptions = prev.filter((_, i) => i !== index);
|
||||||
console.log("📻 RadioType 로컬 옵션 삭제 후:", newLocalOptions);
|
// console.log("📻 RadioType 로컬 옵션 삭제 후:", newLocalOptions);
|
||||||
return newLocalOptions;
|
return newLocalOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,13 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
||||||
|
|
||||||
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
|
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
|
||||||
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
|
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
|
||||||
console.log("📋 SelectTypeConfig 업데이트:", {
|
// console.log("📋 SelectTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -100,12 +100,12 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
||||||
const newOptionData = { ...newOption, disabled: false };
|
const newOptionData = { ...newOption, disabled: false };
|
||||||
const updatedOptions = [...(safeConfig.options || []), newOptionData];
|
const updatedOptions = [...(safeConfig.options || []), newOptionData];
|
||||||
|
|
||||||
console.log("➕ SelectType 옵션 추가:", {
|
// console.log("➕ SelectType 옵션 추가:", {
|
||||||
newOption: newOptionData,
|
// newOption: newOptionData,
|
||||||
updatedOptions,
|
// updatedOptions,
|
||||||
currentLocalOptions: localOptions,
|
// currentLocalOptions: localOptions,
|
||||||
timestamp: new Date().toISOString(),
|
// timestamp: new Date().toISOString(),
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalOptions((prev) => {
|
setLocalOptions((prev) => {
|
||||||
|
|
@ -117,7 +117,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
console.log("📋 SelectType 로컬 옵션 업데이트:", newLocalOptions);
|
// console.log("📋 SelectType 로컬 옵션 업데이트:", newLocalOptions);
|
||||||
return newLocalOptions;
|
return newLocalOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -127,16 +127,16 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeOption = (index: number) => {
|
const removeOption = (index: number) => {
|
||||||
console.log("➖ SelectType 옵션 삭제:", {
|
// console.log("➖ SelectType 옵션 삭제:", {
|
||||||
removeIndex: index,
|
// removeIndex: index,
|
||||||
currentOptions: safeConfig.options,
|
// currentOptions: safeConfig.options,
|
||||||
currentLocalOptions: localOptions,
|
// currentLocalOptions: localOptions,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalOptions((prev) => {
|
setLocalOptions((prev) => {
|
||||||
const newLocalOptions = prev.filter((_, i) => i !== index);
|
const newLocalOptions = prev.filter((_, i) => i !== index);
|
||||||
console.log("📋 SelectType 로컬 옵션 삭제 후:", newLocalOptions);
|
// console.log("📋 SelectType 로컬 옵션 삭제 후:", newLocalOptions);
|
||||||
return newLocalOptions;
|
return newLocalOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,13 +93,13 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
||||||
};
|
};
|
||||||
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||||
console.log("📝 TextTypeConfig 업데이트:", {
|
// console.log("📝 TextTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
localValues,
|
// localValues,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,13 @@ export const TextareaTypeConfigPanel: React.FC<TextareaTypeConfigPanelProps> = (
|
||||||
};
|
};
|
||||||
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||||
console.log("📄 TextareaTypeConfig 업데이트:", {
|
// console.log("📄 TextareaTypeConfig 업데이트:", {
|
||||||
key,
|
// key,
|
||||||
value,
|
// value,
|
||||||
oldConfig: safeConfig,
|
// oldConfig: safeConfig,
|
||||||
newConfig,
|
// newConfig,
|
||||||
localValues,
|
// localValues,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
const newState = updater(currentState);
|
const newState = updater(currentState);
|
||||||
(window as any).globalFileState = newState;
|
(window as any).globalFileState = newState;
|
||||||
|
|
||||||
console.log("🌐 FileUpload 전역 파일 상태 업데이트:", {
|
// console.log("🌐 FileUpload 전역 파일 상태 업데이트:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
newFileCount: newState[component.id]?.length || 0
|
// newFileCount: newState[component.id]?.length || 0
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 강제 리렌더링을 위한 이벤트 발생
|
// 강제 리렌더링을 위한 이벤트 발생
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
|
@ -52,12 +52,12 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
const componentFiles = component.uploadedFiles || [];
|
const componentFiles = component.uploadedFiles || [];
|
||||||
const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
console.log("🚀 FileUpload 파일 상태 초기화:", {
|
// console.log("🚀 FileUpload 파일 상태 초기화:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
globalFiles: globalFiles.length,
|
// globalFiles: globalFiles.length,
|
||||||
componentFiles: componentFiles.length,
|
// componentFiles: componentFiles.length,
|
||||||
finalFiles: finalFiles.length
|
// finalFiles: finalFiles.length
|
||||||
});
|
// });
|
||||||
|
|
||||||
return finalFiles;
|
return finalFiles;
|
||||||
};
|
};
|
||||||
|
|
@ -70,10 +70,10 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
if (event.detail.componentId === component.id) {
|
if (event.detail.componentId === component.id) {
|
||||||
const globalFiles = getGlobalFileState()[component.id] || [];
|
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||||
console.log("🔄 FileUpload 전역 상태 변경 감지:", {
|
// console.log("🔄 FileUpload 전역 상태 변경 감지:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
newFileCount: globalFiles.length
|
// newFileCount: globalFiles.length
|
||||||
});
|
// });
|
||||||
setLocalUploadedFiles(globalFiles);
|
setLocalUploadedFiles(globalFiles);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -101,53 +101,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
|
|
||||||
// 사용자 정보 디버깅
|
// 사용자 정보 디버깅
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", {
|
// console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", {
|
||||||
isLoading,
|
// isLoading,
|
||||||
isLoggedIn,
|
// isLoggedIn,
|
||||||
hasUser: !!user,
|
// hasUser: !!user,
|
||||||
user: user,
|
// user: user,
|
||||||
userId: user?.userId,
|
// userId: user?.userId,
|
||||||
company_code: user?.company_code,
|
// company_code: user?.company_code,
|
||||||
companyCode: user?.companyCode,
|
// companyCode: user?.companyCode,
|
||||||
userType: typeof user,
|
// userType: typeof user,
|
||||||
userKeys: user ? Object.keys(user) : "no user",
|
// userKeys: user ? Object.keys(user) : "no user",
|
||||||
userValues: user ? Object.entries(user) : "no user",
|
// userValues: user ? Object.entries(user) : "no user",
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 사용자 정보가 유효하면 initialUser와 userRef 업데이트
|
// 사용자 정보가 유효하면 initialUser와 userRef 업데이트
|
||||||
if (user && user.userId) {
|
if (user && user.userId) {
|
||||||
setInitialUser(user);
|
setInitialUser(user);
|
||||||
userRef.current = user; // 🎯 ref에도 최신 정보 저장
|
userRef.current = user; // 🎯 ref에도 최신 정보 저장
|
||||||
console.log("✅ 초기 사용자 정보 업데이트:", { userId: user.userId, companyCode: user.companyCode });
|
// console.log("✅ 초기 사용자 정보 업데이트:", { userId: user.userId, companyCode: user.companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사 관련 필드들 확인
|
// 회사 관련 필드들 확인
|
||||||
if (user) {
|
if (user) {
|
||||||
console.log("🔍 회사 관련 필드 검색:", {
|
// console.log("🔍 회사 관련 필드 검색:", {
|
||||||
company_code: user.company_code,
|
// company_code: user.company_code,
|
||||||
companyCode: user.companyCode,
|
// companyCode: user.companyCode,
|
||||||
company: user.company,
|
// company: user.company,
|
||||||
deptCode: user.deptCode,
|
// deptCode: user.deptCode,
|
||||||
partnerCd: user.partnerCd,
|
// partnerCd: user.partnerCd,
|
||||||
// 모든 필드에서 company 관련된 것들 찾기
|
// 모든 필드에서 company 관련된 것들 찾기
|
||||||
allFields: Object.keys(user).filter(
|
// allFields: Object.keys(user).filter(
|
||||||
(key) =>
|
// (key) =>
|
||||||
key.toLowerCase().includes("company") ||
|
// key.toLowerCase().includes("company") ||
|
||||||
key.toLowerCase().includes("corp") ||
|
// key.toLowerCase().includes("corp") ||
|
||||||
key.toLowerCase().includes("code"),
|
// key.toLowerCase().includes("code"),
|
||||||
),
|
// ),
|
||||||
});
|
// });
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요");
|
// console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요");
|
||||||
}
|
}
|
||||||
}, [user, isLoading, isLoggedIn]);
|
}, [user, isLoading, isLoggedIn]);
|
||||||
|
|
||||||
// 컴포넌트 props가 변경될 때 로컬 상태 동기화
|
// 컴포넌트 props가 변경될 때 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔄 File 컴포넌트 props 변경:", {
|
// console.log("🔄 File 컴포넌트 props 변경:", {
|
||||||
propsUploadedFiles: component.uploadedFiles?.length || 0,
|
// propsUploadedFiles: component.uploadedFiles?.length || 0,
|
||||||
localUploadedFiles: localUploadedFiles.length,
|
// localUploadedFiles: localUploadedFiles.length,
|
||||||
});
|
// });
|
||||||
setLocalUploadedFiles(component.uploadedFiles || []);
|
setLocalUploadedFiles(component.uploadedFiles || []);
|
||||||
}, [component.uploadedFiles]);
|
}, [component.uploadedFiles]);
|
||||||
|
|
||||||
|
|
@ -176,23 +176,23 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
const isFileTypeAllowed = (file: File): boolean => {
|
const isFileTypeAllowed = (file: File): boolean => {
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
|
|
||||||
console.log("🔍 파일 타입 검증:", {
|
// console.log("🔍 파일 타입 검증:", {
|
||||||
fileName: file.name,
|
// fileName: file.name,
|
||||||
fileType: file.type,
|
// fileType: file.type,
|
||||||
acceptRules: fileConfig.accept,
|
// acceptRules: fileConfig.accept,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const result = fileConfig.accept.some((accept) => {
|
const result = fileConfig.accept.some((accept) => {
|
||||||
// 모든 파일 허용 (와일드카드)
|
// 모든 파일 허용 (와일드카드)
|
||||||
if (accept === "*/*" || accept === "*") {
|
if (accept === "*/*" || accept === "*") {
|
||||||
console.log("✅ 와일드카드 매칭:", accept);
|
// console.log("✅ 와일드카드 매칭:", accept);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 확장자 기반 검증 (.jpg, .png 등)
|
// 확장자 기반 검증 (.jpg, .png 등)
|
||||||
if (accept.startsWith(".")) {
|
if (accept.startsWith(".")) {
|
||||||
const matches = fileName.endsWith(accept.toLowerCase());
|
const matches = fileName.endsWith(accept.toLowerCase());
|
||||||
console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches);
|
// console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches);
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,97 +200,97 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
if (accept.includes("/*")) {
|
if (accept.includes("/*")) {
|
||||||
const type = accept.split("/")[0];
|
const type = accept.split("/")[0];
|
||||||
const matches = file.type.startsWith(type);
|
const matches = file.type.startsWith(type);
|
||||||
console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches);
|
// console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches);
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등)
|
// 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등)
|
||||||
const matches = file.type === accept;
|
const matches = file.type === accept;
|
||||||
console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches);
|
// console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches);
|
||||||
return matches;
|
return matches;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🎯 최종 검증 결과:`, result);
|
// console.log(`🎯 최종 검증 결과:`, result);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileSelect = useCallback(
|
const handleFileSelect = useCallback(
|
||||||
(files: FileList | null) => {
|
(files: FileList | null) => {
|
||||||
console.log("📁 파일 선택됨:", files ? Array.from(files).map((f) => f.name) : "없음");
|
// console.log("📁 파일 선택됨:", files ? Array.from(files).map((f) => f.name) : "없음");
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
console.log("🔍 파일 검증 시작:", {
|
// console.log("🔍 파일 검증 시작:", {
|
||||||
totalFiles: fileArray.length,
|
// totalFiles: fileArray.length,
|
||||||
currentUploadedCount: uploadedFiles.length,
|
// currentUploadedCount: uploadedFiles.length,
|
||||||
maxFiles: fileConfig.maxFiles,
|
// maxFiles: fileConfig.maxFiles,
|
||||||
maxSize: fileConfig.maxSize,
|
// maxSize: fileConfig.maxSize,
|
||||||
allowedTypes: fileConfig.accept,
|
// allowedTypes: fileConfig.accept,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 파일 검증
|
// 파일 검증
|
||||||
fileArray.forEach((file) => {
|
fileArray.forEach((file) => {
|
||||||
console.log(`📄 파일 검증: ${file.name} (${file.size} bytes, ${file.type})`);
|
// console.log(`📄 파일 검증: ${file.name} (${file.size} bytes, ${file.type})`);
|
||||||
|
|
||||||
// 파일 타입 검증
|
// 파일 타입 검증
|
||||||
if (!isFileTypeAllowed(file)) {
|
if (!isFileTypeAllowed(file)) {
|
||||||
errors.push(`${file.name}: 허용되지 않는 파일 타입입니다.`);
|
errors.push(`${file.name}: 허용되지 않는 파일 타입입니다.`);
|
||||||
console.log(`❌ 파일 타입 거부: ${file.name}`);
|
// console.log(`❌ 파일 타입 거부: ${file.name}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 크기 검증
|
// 파일 크기 검증
|
||||||
if (file.size > fileConfig.maxSize * 1024 * 1024) {
|
if (file.size > fileConfig.maxSize * 1024 * 1024) {
|
||||||
errors.push(`${file.name}: 파일 크기가 ${fileConfig.maxSize}MB를 초과합니다.`);
|
errors.push(`${file.name}: 파일 크기가 ${fileConfig.maxSize}MB를 초과합니다.`);
|
||||||
console.log(`❌ 파일 크기 초과: ${file.name} (${file.size} > ${fileConfig.maxSize * 1024 * 1024})`);
|
// console.log(`❌ 파일 크기 초과: ${file.name} (${file.size} > ${fileConfig.maxSize * 1024 * 1024})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 최대 파일 수 검증
|
// 최대 파일 수 검증
|
||||||
if (uploadedFiles.length + validFiles.length >= fileConfig.maxFiles) {
|
if (uploadedFiles.length + validFiles.length >= fileConfig.maxFiles) {
|
||||||
errors.push(`최대 ${fileConfig.maxFiles}개까지만 업로드할 수 있습니다.`);
|
errors.push(`최대 ${fileConfig.maxFiles}개까지만 업로드할 수 있습니다.`);
|
||||||
console.log(`❌ 최대 파일 수 초과`);
|
// console.log(`❌ 최대 파일 수 초과`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
console.log(`✅ 파일 검증 통과: ${file.name}`);
|
// console.log(`✅ 파일 검증 통과: ${file.name}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 에러가 있으면 알림
|
// 에러가 있으면 알림
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.error("💥 파일 업로드 오류:", errors);
|
// console.error("💥 파일 업로드 오류:", errors);
|
||||||
// TODO: Toast 알림 표시
|
// TODO: Toast 알림 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효한 파일들을 업로드 큐에 추가
|
// 유효한 파일들을 업로드 큐에 추가
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
console.log(
|
// console.log(
|
||||||
"✅ 유효한 파일들 업로드 큐에 추가:",
|
// "✅ 유효한 파일들 업로드 큐에 추가:",
|
||||||
validFiles.map((f) => f.name),
|
// validFiles.map((f) => f.name),
|
||||||
);
|
// );
|
||||||
setUploadQueue((prev) => [...prev, ...validFiles]);
|
setUploadQueue((prev) => [...prev, ...validFiles]);
|
||||||
|
|
||||||
if (fileConfig.autoUpload) {
|
if (fileConfig.autoUpload) {
|
||||||
console.log("🚀 자동 업로드 시작:", {
|
// console.log("🚀 자동 업로드 시작:", {
|
||||||
autoUpload: fileConfig.autoUpload,
|
// autoUpload: fileConfig.autoUpload,
|
||||||
filesCount: validFiles.length,
|
// filesCount: validFiles.length,
|
||||||
fileNames: validFiles.map((f) => f.name),
|
// fileNames: validFiles.map((f) => f.name),
|
||||||
});
|
// });
|
||||||
// 자동 업로드 실행
|
// 자동 업로드 실행
|
||||||
validFiles.forEach(uploadFile);
|
validFiles.forEach(uploadFile);
|
||||||
} else {
|
} else {
|
||||||
console.log("⏸️ 자동 업로드 비활성화:", {
|
// console.log("⏸️ 자동 업로드 비활성화:", {
|
||||||
autoUpload: fileConfig.autoUpload,
|
// autoUpload: fileConfig.autoUpload,
|
||||||
filesCount: validFiles.length,
|
// filesCount: validFiles.length,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ 업로드할 유효한 파일이 없음");
|
// console.log("❌ 업로드할 유효한 파일이 없음");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fileConfig, uploadedFiles.length],
|
[fileConfig, uploadedFiles.length],
|
||||||
|
|
@ -299,7 +299,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
// 파일 업로드 함수 (실시간 상태 조회로 타이밍 문제 해결)
|
// 파일 업로드 함수 (실시간 상태 조회로 타이밍 문제 해결)
|
||||||
const uploadFile = useCallback(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
console.log("📤 파일 업로드 시작:", file.name);
|
// console.log("📤 파일 업로드 시작:", file.name);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("files", file);
|
formData.append("files", file);
|
||||||
|
|
@ -311,31 +311,31 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
const currentUser = userRef.current;
|
const currentUser = userRef.current;
|
||||||
|
|
||||||
// 실시간 사용자 정보 디버깅
|
// 실시간 사용자 정보 디버깅
|
||||||
console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", {
|
// console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", {
|
||||||
hasCurrentUser: !!currentUser,
|
// hasCurrentUser: !!currentUser,
|
||||||
currentUser: currentUser
|
// currentUser: currentUser
|
||||||
? {
|
// ? {
|
||||||
userId: currentUser.userId,
|
// userId: currentUser.userId,
|
||||||
companyCode: currentUser.companyCode,
|
// companyCode: currentUser.companyCode,
|
||||||
company_code: currentUser.company_code,
|
// company_code: currentUser.company_code,
|
||||||
}
|
// }
|
||||||
: null,
|
// : null,
|
||||||
// 기존 상태와 비교
|
// 기존 상태와 비교
|
||||||
originalUser: user,
|
// originalUser: user,
|
||||||
originalInitialUser: initialUser,
|
// originalInitialUser: initialUser,
|
||||||
refExists: !!userRef.current,
|
// refExists: !!userRef.current,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 사용자 정보가 로드되지 않은 경우 잠시 대기
|
// 사용자 정보가 로드되지 않은 경우 잠시 대기
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
console.log("⏳ 사용자 정보 로딩 중... 업로드 대기");
|
// console.log("⏳ 사용자 정보 로딩 중... 업로드 대기");
|
||||||
setTimeout(() => uploadFile(file), 500); // 500ms 후 재시도
|
setTimeout(() => uploadFile(file), 500); // 500ms 후 재시도
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정보가 없는 경우 - 무한루프 방지로 재시도 제한
|
// 사용자 정보가 없는 경우 - 무한루프 방지로 재시도 제한
|
||||||
if (!user && isLoggedIn) {
|
if (!user && isLoggedIn) {
|
||||||
console.warn("⚠️ 로그인은 되어 있지만 사용자 정보가 없음. DEFAULT로 진행");
|
// console.warn("⚠️ 로그인은 되어 있지만 사용자 정보가 없음. DEFAULT로 진행");
|
||||||
// 무한루프 방지: 재시도하지 않고 DEFAULT로 진행
|
// 무한루프 방지: 재시도하지 않고 DEFAULT로 진행
|
||||||
// setTimeout(() => uploadFile(file), 1000); // 1초 후 재시도
|
// setTimeout(() => uploadFile(file), 1000); // 1초 후 재시도
|
||||||
// return;
|
// return;
|
||||||
|
|
@ -348,27 +348,27 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
if (companyCode) {
|
if (companyCode) {
|
||||||
// "*"는 실제 회사코드이므로 그대로 사용
|
// "*"는 실제 회사코드이므로 그대로 사용
|
||||||
formData.append("companyCode", companyCode);
|
formData.append("companyCode", companyCode);
|
||||||
console.log("✅ 회사코드 추가:", companyCode);
|
// console.log("✅ 회사코드 추가:", companyCode);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", {
|
// console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", {
|
||||||
user: user,
|
// user: user,
|
||||||
initialUser: initialUser,
|
// initialUser: initialUser,
|
||||||
effectiveUser: effectiveUser,
|
// effectiveUser: effectiveUser,
|
||||||
companyCode: effectiveUser?.companyCode,
|
// companyCode: effectiveUser?.companyCode,
|
||||||
company_code: effectiveUser?.company_code,
|
// company_code: effectiveUser?.company_code,
|
||||||
deptCode: effectiveUser?.deptCode,
|
// deptCode: effectiveUser?.deptCode,
|
||||||
isLoading,
|
// isLoading,
|
||||||
isLoggedIn,
|
// isLoggedIn,
|
||||||
allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
|
// allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
|
||||||
});
|
// });
|
||||||
formData.append("companyCode", "DEFAULT");
|
formData.append("companyCode", "DEFAULT");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveUser?.userId) {
|
if (effectiveUser?.userId) {
|
||||||
formData.append("writer", effectiveUser.userId);
|
formData.append("writer", effectiveUser.userId);
|
||||||
console.log("✅ 작성자 추가:", effectiveUser.userId);
|
// console.log("✅ 작성자 추가:", effectiveUser.userId);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 사용자ID가 없음, system 사용");
|
// console.warn("⚠️ 사용자ID가 없음, system 사용");
|
||||||
formData.append("writer", "system");
|
formData.append("writer", "system");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,45 +376,45 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
if (fileConfig.accept && fileConfig.accept.length > 0) {
|
if (fileConfig.accept && fileConfig.accept.length > 0) {
|
||||||
const acceptString = fileConfig.accept.join(",");
|
const acceptString = fileConfig.accept.join(",");
|
||||||
formData.append("accept", acceptString);
|
formData.append("accept", acceptString);
|
||||||
console.log("✅ 허용 파일 타입 추가:", acceptString);
|
// console.log("✅ 허용 파일 타입 추가:", acceptString);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자동 연결 정보 추가
|
// 자동 연결 정보 추가
|
||||||
if (fileConfig.autoLink) {
|
if (fileConfig.autoLink) {
|
||||||
formData.append("autoLink", "true");
|
formData.append("autoLink", "true");
|
||||||
console.log("✅ 자동 연결 활성화: true");
|
// console.log("✅ 자동 연결 활성화: true");
|
||||||
|
|
||||||
if (fileConfig.linkedTable) {
|
if (fileConfig.linkedTable) {
|
||||||
formData.append("linkedTable", fileConfig.linkedTable);
|
formData.append("linkedTable", fileConfig.linkedTable);
|
||||||
console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable);
|
// console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileConfig.linkedField) {
|
if (fileConfig.linkedField) {
|
||||||
formData.append("linkedField", fileConfig.linkedField);
|
formData.append("linkedField", fileConfig.linkedField);
|
||||||
console.log("✅ 연결 필드 추가:", fileConfig.linkedField);
|
// console.log("✅ 연결 필드 추가:", fileConfig.linkedField);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileConfig.recordId) {
|
if (fileConfig.recordId) {
|
||||||
formData.append("recordId", fileConfig.recordId);
|
formData.append("recordId", fileConfig.recordId);
|
||||||
console.log("✅ 레코드 ID 추가:", fileConfig.recordId);
|
// console.log("✅ 레코드 ID 추가:", fileConfig.recordId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가상 파일 컬럼 정보 추가
|
// 가상 파일 컬럼 정보 추가
|
||||||
if (fileConfig.isVirtualFileColumn) {
|
if (fileConfig.isVirtualFileColumn) {
|
||||||
formData.append("isVirtualFileColumn", "true");
|
formData.append("isVirtualFileColumn", "true");
|
||||||
console.log("✅ 가상 파일 컬럼 활성화: true");
|
// console.log("✅ 가상 파일 컬럼 활성화: true");
|
||||||
|
|
||||||
if (fileConfig.columnName) {
|
if (fileConfig.columnName) {
|
||||||
formData.append("columnName", fileConfig.columnName);
|
formData.append("columnName", fileConfig.columnName);
|
||||||
console.log("✅ 컬럼명 추가:", fileConfig.columnName);
|
// console.log("✅ 컬럼명 추가:", fileConfig.columnName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormData 내용 디버깅
|
// FormData 내용 디버깅
|
||||||
console.log("📋 FormData 내용 확인:");
|
// console.log("📋 FormData 내용 확인:");
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
console.log(` ${key}:`, value);
|
// console.log(` ${key}:`, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -437,23 +437,23 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
isUploading: true,
|
isUploading: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📋 임시 파일 정보 생성:", tempFileInfo);
|
// console.log("📋 임시 파일 정보 생성:", tempFileInfo);
|
||||||
|
|
||||||
const newUploadedFiles = [...uploadedFiles, tempFileInfo];
|
const newUploadedFiles = [...uploadedFiles, tempFileInfo];
|
||||||
console.log("📊 업데이트 전 파일 목록:", uploadedFiles.length, "개");
|
// console.log("📊 업데이트 전 파일 목록:", uploadedFiles.length, "개");
|
||||||
console.log("📊 업데이트 후 파일 목록:", newUploadedFiles.length, "개");
|
// console.log("📊 업데이트 후 파일 목록:", newUploadedFiles.length, "개");
|
||||||
|
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalUploadedFiles(newUploadedFiles);
|
setLocalUploadedFiles(newUploadedFiles);
|
||||||
|
|
||||||
// 임시 파일 정보를 업로드된 파일 목록에 추가
|
// 임시 파일 정보를 업로드된 파일 목록에 추가
|
||||||
console.log("🔄 onUpdateComponent 호출 중...");
|
// console.log("🔄 onUpdateComponent 호출 중...");
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
uploadedFiles: newUploadedFiles,
|
uploadedFiles: newUploadedFiles,
|
||||||
});
|
});
|
||||||
console.log("✅ onUpdateComponent 호출 완료");
|
// console.log("✅ onUpdateComponent 호출 완료");
|
||||||
|
|
||||||
console.log("🚀 API 호출 시작 - /files/upload");
|
// console.log("🚀 API 호출 시작 - /files/upload");
|
||||||
|
|
||||||
// 실제 API 호출 (apiClient 사용으로 자동 JWT 토큰 추가)
|
// 실제 API 호출 (apiClient 사용으로 자동 JWT 토큰 추가)
|
||||||
// FormData 사용 시 Content-Type을 삭제하여 boundary가 자동 설정되도록 함
|
// FormData 사용 시 Content-Type을 삭제하여 boundary가 자동 설정되도록 함
|
||||||
|
|
@ -464,7 +464,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
console.log("📡 API 응답 성공:", result);
|
// console.log("📡 API 응답 성공:", result);
|
||||||
|
|
||||||
if (!result.success || !result.files || result.files.length === 0) {
|
if (!result.success || !result.files || result.files.length === 0) {
|
||||||
throw new Error(result.message || "파일 업로드 실패");
|
throw new Error(result.message || "파일 업로드 실패");
|
||||||
|
|
@ -491,7 +491,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("✅ 실제 파일 업로드 완료 (attach_file_info 저장됨):", successFileInfo);
|
// console.log("✅ 실제 파일 업로드 완료 (attach_file_info 저장됨):", successFileInfo);
|
||||||
|
|
||||||
const updatedFiles = uploadedFiles.map((f) => (f.objid === tempFileInfo.objid ? successFileInfo : f));
|
const updatedFiles = uploadedFiles.map((f) => (f.objid === tempFileInfo.objid ? successFileInfo : f));
|
||||||
|
|
||||||
|
|
@ -514,14 +514,14 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
|
// console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
|
||||||
|
|
||||||
const event = new CustomEvent('globalFileStateChanged', {
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: eventDetail
|
detail: eventDetail
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
|
// console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 업데이트 (옵셔널)
|
// 컴포넌트 업데이트 (옵셔널)
|
||||||
|
|
@ -539,21 +539,21 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
// 업로드 큐에서 제거
|
// 업로드 큐에서 제거
|
||||||
setUploadQueue((prev) => prev.filter((f) => f !== file));
|
setUploadQueue((prev) => prev.filter((f) => f !== file));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 파일 업로드 실패:", {
|
// console.error("❌ 파일 업로드 실패:", {
|
||||||
error,
|
// error,
|
||||||
errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
|
// errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
errorStack: error instanceof Error ? error.stack : undefined,
|
// errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
|
// user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
|
||||||
authState: { isLoading, isLoggedIn },
|
// authState: { isLoading, isLoggedIn },
|
||||||
});
|
// });
|
||||||
|
|
||||||
// API 응답 에러인 경우 상세 정보 출력
|
// API 응답 에러인 경우 상세 정보 출력
|
||||||
if ((error as any)?.response) {
|
if ((error as any)?.response) {
|
||||||
console.error("📡 API 응답 에러:", {
|
// console.error("📡 API 응답 에러:", {
|
||||||
status: (error as any).response.status,
|
// status: (error as any).response.status,
|
||||||
statusText: (error as any).response.statusText,
|
// statusText: (error as any).response.statusText,
|
||||||
data: (error as any).response.data,
|
// data: (error as any).response.data,
|
||||||
});
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 에러 상태로 업데이트
|
// 에러 상태로 업데이트
|
||||||
|
|
@ -576,7 +576,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
|
|
||||||
// 파일 삭제
|
// 파일 삭제
|
||||||
const deleteFile = async (fileInfo: AttachedFileInfo) => {
|
const deleteFile = async (fileInfo: AttachedFileInfo) => {
|
||||||
console.log("🗑️ 파일 삭제:", fileInfo.realFileName);
|
// console.log("🗑️ 파일 삭제:", fileInfo.realFileName);
|
||||||
try {
|
try {
|
||||||
// 실제 API 호출 (논리적 삭제) - apiClient 사용으로 JWT 토큰 자동 추가
|
// 실제 API 호출 (논리적 삭제) - apiClient 사용으로 JWT 토큰 자동 추가
|
||||||
const response = await apiClient.delete(`/files/${fileInfo.objid}`, {
|
const response = await apiClient.delete(`/files/${fileInfo.objid}`, {
|
||||||
|
|
@ -586,7 +586,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
console.log("📡 파일 삭제 API 응답:", result);
|
// console.log("📡 파일 삭제 API 응답:", result);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || "파일 삭제 실패");
|
throw new Error(result.message || "파일 삭제 실패");
|
||||||
|
|
@ -615,39 +615,39 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
|
// console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
|
||||||
|
|
||||||
const event = new CustomEvent('globalFileStateChanged', {
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: eventDetail
|
detail: eventDetail
|
||||||
});
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
|
// console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||||
|
|
||||||
// 추가 지연 이벤트들
|
// 추가 지연 이벤트들
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
// console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true }
|
detail: { ...eventDetail, delayed: true }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
// console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
// console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
// console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("FileUpload 이벤트 발생 실패:", error);
|
// console.warn("FileUpload 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,9 +655,9 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
uploadedFiles: filteredFiles,
|
uploadedFiles: filteredFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 파일 삭제 완료 (attach_file_info.status = DELETED)");
|
// console.log("✅ 파일 삭제 완료 (attach_file_info.status = DELETED)");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 삭제 실패:", error);
|
// console.error("파일 삭제 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -692,10 +692,10 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
// TODO: 이미지 미리보기 모달 열기
|
// TODO: 이미지 미리보기 모달 열기
|
||||||
console.log("이미지 미리보기:", fileInfo);
|
// console.log("이미지 미리보기:", fileInfo);
|
||||||
} else {
|
} else {
|
||||||
// TODO: 파일 다운로드
|
// TODO: 파일 다운로드
|
||||||
console.log("파일 다운로드:", fileInfo);
|
// console.log("파일 다운로드:", fileInfo);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -703,20 +703,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{/* 드래그 앤 드롭 영역 */}
|
{/* 드래그 앤 드롭 영역 */}
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
|
className={`group relative rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-300 ${
|
||||||
isDragOver ? "border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm" : "border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm"
|
isDragOver
|
||||||
|
? "border-blue-500 bg-gradient-to-br from-blue-100/90 to-indigo-100/80 shadow-xl shadow-blue-500/20 scale-105"
|
||||||
|
: "border-gray-300/60 bg-gradient-to-br from-gray-50/80 to-blue-50/40 hover:border-blue-400/80 hover:bg-gradient-to-br hover:from-blue-50/90 hover:to-indigo-50/60 hover:shadow-lg hover:shadow-blue-500/10"
|
||||||
}`}
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
<div className="relative">
|
||||||
<p className="mb-2 text-lg font-medium text-gray-900">
|
<Upload className={`mx-auto mb-4 h-16 w-16 transition-all duration-300 ${
|
||||||
|
isDragOver
|
||||||
|
? "text-blue-500 scale-110"
|
||||||
|
: "text-gray-400 group-hover:text-blue-500 group-hover:scale-105"
|
||||||
|
}`} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={`h-20 w-20 rounded-full transition-all duration-300 ${
|
||||||
|
isDragOver
|
||||||
|
? "bg-blue-200/80 scale-110"
|
||||||
|
: "bg-blue-100/50 opacity-0 group-hover:opacity-100 group-hover:scale-110"
|
||||||
|
}`}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`mb-2 text-xl font-semibold transition-colors duration-300 ${
|
||||||
|
isDragOver
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-700 group-hover:text-blue-600"
|
||||||
|
}`}>
|
||||||
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
|
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-4 text-sm text-gray-500">또는 클릭하여 파일을 선택하세요</p>
|
<p className={`mb-4 text-sm transition-colors duration-300 ${
|
||||||
|
isDragOver
|
||||||
|
? "text-blue-500"
|
||||||
|
: "text-gray-500 group-hover:text-blue-500"
|
||||||
|
}`}>
|
||||||
|
또는 클릭하여 파일을 선택하세요
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4 rounded-lg border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 transition-all duration-200">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleFileInputClick}
|
||||||
|
className={`mb-4 rounded-xl border-2 transition-all duration-200 ${
|
||||||
|
isDragOver
|
||||||
|
? "border-blue-400 bg-blue-50 text-blue-600 shadow-md"
|
||||||
|
: "border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
{fileConfig.uploadButtonText || "파일 선택"}
|
{fileConfig.uploadButtonText || "파일 선택"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -740,27 +773,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
|
|
||||||
{/* 업로드된 파일 목록 */}
|
{/* 업로드된 파일 목록 */}
|
||||||
{uploadedFiles.length > 0 && (
|
{uploadedFiles.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">
|
<div className="flex items-center justify-between rounded-xl bg-gradient-to-r from-blue-50/80 to-indigo-50/60 px-4 py-3 border border-blue-200/40">
|
||||||
첨부된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})
|
<div className="flex items-center space-x-3">
|
||||||
</h4>
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<File className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-gray-800">
|
||||||
|
업로드된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="rounded-lg border-blue-200/60 bg-white/80 hover:bg-blue-50/80">
|
||||||
|
자세히보기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{uploadedFiles.map((fileInfo) => (
|
{uploadedFiles.map((fileInfo) => (
|
||||||
<div key={fileInfo.objid} className="flex items-center justify-between rounded-lg border bg-gray-50 p-3">
|
<div key={fileInfo.objid} className="group relative flex items-center justify-between rounded-xl border border-gray-200/60 bg-white/90 p-4 shadow-sm transition-all duration-200 hover:shadow-md hover:border-blue-300/60 hover:bg-blue-50/30">
|
||||||
<div className="flex flex-1 items-center space-x-3">
|
<div className="flex flex-1 items-center space-x-4">
|
||||||
{getFileIcon(fileInfo.fileExt)}
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-gray-50 to-gray-100/80 shadow-sm">
|
||||||
|
{getFileIcon(fileInfo.fileExt)}
|
||||||
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-gray-900">{fileInfo.realFileName}</p>
|
<p className="truncate text-base font-semibold text-gray-800 group-hover:text-blue-600 transition-colors duration-200">
|
||||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
{fileInfo.realFileName}
|
||||||
<span>{formatFileSize(fileInfo.fileSize)}</span>
|
</p>
|
||||||
<span>•</span>
|
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
||||||
<span>{fileInfo.fileExt.toUpperCase()}</span>
|
<span className="flex items-center space-x-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-blue-400"></div>
|
||||||
|
<span className="font-medium">{formatFileSize(fileInfo.fileSize)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center space-x-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-gray-400"></div>
|
||||||
|
<span className="px-2 py-1 rounded-md bg-gray-100 text-xs font-medium">
|
||||||
|
{fileInfo.fileExt.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{fileInfo.writer && (
|
{fileInfo.writer && (
|
||||||
<>
|
<span className="flex items-center space-x-1">
|
||||||
<span>•</span>
|
<div className="h-1.5 w-1.5 rounded-full bg-green-400"></div>
|
||||||
<span>{fileInfo.writer}</span>
|
<span className="text-xs">{fileInfo.writer}</span>
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -784,35 +843,60 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-2">
|
||||||
{/* 상태 표시 */}
|
{/* 상태 표시 */}
|
||||||
{fileInfo.isUploading && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
|
{fileInfo.isUploading && (
|
||||||
{fileInfo.status === "ACTIVE" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
<div className="flex items-center space-x-2 rounded-lg bg-blue-50 px-3 py-2">
|
||||||
{fileInfo.hasError && <AlertCircle className="h-4 w-4 text-red-500" />}
|
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||||
|
<span className="text-xs font-medium text-blue-600">업로드 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileInfo.status === "ACTIVE" && (
|
||||||
|
<div className="flex items-center space-x-2 rounded-lg bg-green-50 px-3 py-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-xs font-medium text-green-600">완료</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileInfo.hasError && (
|
||||||
|
<div className="flex items-center space-x-2 rounded-lg bg-red-50 px-3 py-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-xs font-medium text-red-600">오류</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 */}
|
||||||
{!fileInfo.isUploading && !fileInfo.hasError && (
|
{!fileInfo.isUploading && !fileInfo.hasError && (
|
||||||
<>
|
<div className="flex items-center space-x-1">
|
||||||
{fileConfig.showPreview && (
|
{fileConfig.showPreview && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => previewFile(fileInfo)}
|
||||||
|
className="h-9 w-9 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-all duration-200"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => previewFile(fileInfo)}
|
||||||
|
className="h-9 w-9 rounded-lg hover:bg-green-50 hover:text-green-600 transition-all duration-200"
|
||||||
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => deleteFile(fileInfo)}
|
onClick={() => deleteFile(fileInfo)}
|
||||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
|
className="h-9 w-9 rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -821,8 +905,18 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 문서 타입 정보 */}
|
{/* 문서 타입 정보 */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-center space-x-2 rounded-xl bg-gradient-to-r from-amber-50/80 to-orange-50/60 border border-amber-200/40 px-4 py-3">
|
||||||
<Badge variant="outline">{fileConfig.docTypeName}</Badge>
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<File className="h-3 w-3 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-amber-700">
|
||||||
|
파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="bg-white/80 border-amber-200/60 text-amber-700">
|
||||||
|
{fileConfig.docTypeName}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
||||||
console.log("Button clicked:", config);
|
// console.log("Button clicked:", config);
|
||||||
|
|
||||||
// onChange를 통해 클릭 이벤트 전달
|
// onChange를 통해 클릭 이벤트 전달
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue