Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-10-08 09:45:59 +09:00
commit 1760703150
239 changed files with 14630 additions and 586582 deletions

604
UI_개선사항_문서.md Normal file
View File

@ -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를 만들 수 있습니다!** 🎨✨

View File

@ -18,13 +18,15 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.4",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
@ -36,13 +38,15 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4",
"@types/imap": "^0.8.42",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.20",
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5",
@ -2312,6 +2316,19 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@ -3184,6 +3201,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/imap": {
"version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -3250,6 +3277,30 @@
"@types/node": "*"
}
},
"node_modules/@types/mailparser": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"iconv-lite": "^0.6.3"
}
},
"node_modules/@types/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -3319,9 +3370,9 @@
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "6.4.19",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz",
"integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==",
"version": "6.4.20",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
"integrity": "sha512-uj83z0GqwqMUE6RI4EKptPlav0FYE6vpIlqJAnxzu+/sSezRdbH69rSBCMsdW6DdsCAzoFQZ52c2UIlhRVQYDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4868,7 +4919,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5022,7 +5072,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@ -5037,7 +5086,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
@ -5050,7 +5098,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@ -5066,7 +5113,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@ -5160,11 +5206,19 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@ -6198,6 +6252,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
@ -6214,11 +6277,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@ -6335,6 +6413,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/imap": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
"dependencies": {
"readable-stream": "1.1.x",
"utf7": ">=1.0.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/imap/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/imap/node_modules/readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/imap/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -7403,6 +7517,15 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -7427,6 +7550,42 @@
"node": ">= 0.8.0"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -7434,6 +7593,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -7554,6 +7722,56 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/mailparser": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.6.3",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"mailsplit": "5.4.5",
"nodemailer": "7.0.4",
"punycode.js": "2.3.1",
"tlds": "1.259.0"
}
},
"node_modules/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/mailsplit": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -8202,6 +8420,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -8264,6 +8495,15 @@
"node": ">=8"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@ -8610,6 +8850,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@ -8924,6 +9173,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@ -9525,6 +9786,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.259.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -9771,6 +10041,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@ -9848,6 +10124,23 @@
"punycode": "^2.1.0"
}
},
"node_modules/utf7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
"dependencies": {
"semver": "~5.3.0"
}
},
"node_modules/utf7/node_modules/semver": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -32,13 +32,15 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.4",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
@ -50,13 +52,15 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4",
"@types/imap": "^0.8.42",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.20",
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5",

View File

@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
@ -157,6 +161,10 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes);
app.use("/api/admin/component-standards", componentStandardRoutes);
app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);

View File

@ -0,0 +1,206 @@
import { Request, Response } from 'express';
import { mailAccountFileService } from '../services/mailAccountFileService';
export class MailAccountFileController {
async getAllAccounts(req: Request, res: Response) {
try {
const accounts = await mailAccountFileService.getAllAccounts();
// 비밀번호는 반환하지 않음
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
return res.json({
success: true,
data: safeAccounts,
total: safeAccounts.length,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 조회 실패',
error: err.message,
});
}
}
async getAccountById(req: Request, res: Response) {
try {
const { id } = req.params;
const account = await mailAccountFileService.getAccountById(id);
if (!account) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
// 비밀번호는 마스킹 처리
const { smtpPassword, ...safeAccount } = account;
return res.json({
success: true,
data: {
...safeAccount,
smtpPassword: '••••••••', // 마스킹
},
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 조회 실패',
error: err.message,
});
}
}
async createAccount(req: Request, res: Response) {
try {
const {
name,
email,
smtpHost,
smtpPort,
smtpSecure,
smtpUsername,
smtpPassword,
dailyLimit,
status,
} = req.body;
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다.',
});
}
// 이메일 중복 확인
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
if (existingAccount) {
return res.status(400).json({
success: false,
message: '이미 등록된 이메일입니다.',
});
}
const account = await mailAccountFileService.createAccount({
name,
email,
smtpHost,
smtpPort,
smtpSecure: smtpSecure || false,
smtpUsername,
smtpPassword,
dailyLimit: dailyLimit || 1000,
status: status || 'active',
});
// 비밀번호 제외하고 반환
const { smtpPassword: _, ...safeAccount } = account;
return res.status(201).json({
success: true,
data: safeAccount,
message: '메일 계정이 생성되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 생성 실패',
error: err.message,
});
}
}
async updateAccount(req: Request, res: Response) {
try {
const { id } = req.params;
const updates = req.body;
const account = await mailAccountFileService.updateAccount(id, updates);
if (!account) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
// 비밀번호 제외하고 반환
const { smtpPassword: _, ...safeAccount } = account;
return res.json({
success: true,
data: safeAccount,
message: '계정이 수정되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 수정 실패',
error: err.message,
});
}
}
async deleteAccount(req: Request, res: Response) {
try {
const { id } = req.params;
const success = await mailAccountFileService.deleteAccount(id);
if (!success) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
message: '계정이 삭제되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 삭제 실패',
error: err.message,
});
}
}
async testConnection(req: Request, res: Response) {
try {
const { id } = req.params;
const account = await mailAccountFileService.getAccountById(id);
if (!account) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
// mailSendSimpleService의 testConnection 사용
const { mailSendSimpleService } = require('../services/mailSendSimpleService');
const result = await mailSendSimpleService.testConnection(id);
return res.json(result);
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '연결 테스트 실패',
error: err.message,
});
}
}
}
export const mailAccountFileController = new MailAccountFileController();

View File

@ -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 연결 테스트 실패',
});
}
}
}

View File

@ -0,0 +1,98 @@
import { Request, Response } from 'express';
import { mailSendSimpleService } from '../services/mailSendSimpleService';
export class MailSendSimpleController {
/**
* ( )
*/
async sendMail(req: Request, res: Response) {
try {
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
// 필수 파라미터 검증
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
console.log('❌ 필수 파라미터 누락');
return res.status(400).json({
success: false,
message: '계정 ID와 수신자 이메일이 필요합니다.',
});
}
if (!subject) {
return res.status(400).json({
success: false,
message: '메일 제목이 필요합니다.',
});
}
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
if (!templateId && !customHtml) {
return res.status(400).json({
success: false,
message: '템플릿 또는 메일 내용이 필요합니다.',
});
}
// 메일 발송
const result = await mailSendSimpleService.sendMail({
accountId,
templateId,
to,
subject,
variables,
customHtml,
});
if (result.success) {
return res.json({
success: true,
data: result,
message: '메일이 발송되었습니다.',
});
} else {
return res.status(500).json({
success: false,
message: result.error || '메일 발송 실패',
});
}
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '메일 발송 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
* SMTP
*/
async testConnection(req: Request, res: Response) {
try {
const { accountId } = req.body;
if (!accountId) {
return res.status(400).json({
success: false,
message: '계정 ID가 필요합니다.',
});
}
const result = await mailSendSimpleService.testConnection(accountId);
return res.json(result);
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '연결 테스트 실패',
error: err.message,
});
}
}
}
export const mailSendSimpleController = new MailSendSimpleController();

View File

@ -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();

View File

@ -0,0 +1,18 @@
import { Router } from 'express';
import { mailAccountFileController } from '../controllers/mailAccountFileController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
// 모든 메일 계정 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
router.get('/', (req, res) => mailAccountFileController.getAllAccounts(req, res));
router.get('/:id', (req, res) => mailAccountFileController.getAccountById(req, res));
router.post('/', (req, res) => mailAccountFileController.createAccount(req, res));
router.put('/:id', (req, res) => mailAccountFileController.updateAccount(req, res));
router.delete('/:id', (req, res) => mailAccountFileController.deleteAccount(req, res));
router.post('/:id/test-connection', (req, res) => mailAccountFileController.testConnection(req, res));
export default router;

View File

@ -0,0 +1,31 @@
/**
* (Step 2 - )
*/
import express from 'express';
import { MailReceiveBasicController } from '../controllers/mailReceiveBasicController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = express.Router();
// 모든 메일 수신 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
const controller = new MailReceiveBasicController();
// 메일 목록 조회
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
// 메일 상세 조회
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
// 메일 읽음 표시
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
// IMAP 연결 테스트
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
export default router;

View File

@ -0,0 +1,17 @@
import { Router } from 'express';
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
// 모든 메일 발송 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// POST /api/mail/send/simple - 메일 발송
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
// POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
export default router;

View File

@ -0,0 +1,22 @@
import { Router } from 'express';
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
// 모든 메일 템플릿 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 템플릿 CRUD
router.get('/', (req, res) => mailTemplateFileController.getAllTemplates(req, res));
router.get('/:id', (req, res) => mailTemplateFileController.getTemplateById(req, res));
router.post('/', (req, res) => mailTemplateFileController.createTemplate(req, res));
router.put('/:id', (req, res) => mailTemplateFileController.updateTemplate(req, res));
router.delete('/:id', (req, res) => mailTemplateFileController.deleteTemplate(req, res));
// 미리보기
router.post('/:id/preview', (req, res) => mailTemplateFileController.previewTemplate(req, res));
router.post('/:id/preview-with-query', (req, res) => mailTemplateFileController.previewWithQuery(req, res));
export default router;

View File

@ -934,23 +934,14 @@ export class DataflowControlService {
}
/**
* DELETE -
* DELETE - DB
*/
private async executeDeleteAction(
action: ControlAction,
sourceData: Record<string, any>
): Promise<any> {
console.log(`🗑️ DELETE 액션 실행 시작:`, {
actionName: action.name,
conditions: action.conditions,
});
// DELETE는 조건이 필수
if (!action.conditions || action.conditions.length === 0) {
throw new Error(
"DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다."
);
}
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
const results = [];
@ -964,7 +955,7 @@ export class DataflowControlService {
condition.value !== undefined
) {
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
const parts = condition.field.split(".");
const parts = condition.field!.split(".");
let tableName: string;
let fieldName: string;
@ -982,7 +973,7 @@ export class DataflowControlService {
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
);
}
fieldName = condition.field;
fieldName = condition.field!;
}
if (!tableGroups.has(tableName)) {
@ -1044,14 +1035,14 @@ export class DataflowControlService {
targetTable: tableName,
whereClause,
});
} catch (error) {
} catch (error: unknown) {
console.error(`❌ DELETE 실패:`, {
table: tableName,
error: error,
});
const userFriendlyMessage =
error instanceof Error ? error.message : String(error);
error instanceof Error ? (error as Error).message : String(error);
results.push({
message: `DELETE 실패: ${tableName}`,

View File

@ -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();

View File

@ -745,6 +745,30 @@ export class ExternalDbConnectionService {
params: any[] = []
): Promise<ApiResponse<any[]>> {
try {
// 보안 검증: SELECT 쿼리만 허용
const trimmedQuery = query.trim().toUpperCase();
if (!trimmedQuery.startsWith('SELECT')) {
console.log("보안 오류: SELECT가 아닌 쿼리 시도:", { id, query: query.substring(0, 100) });
return {
success: false,
message: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다.",
};
}
// 위험한 키워드 검사
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE', 'CALL', 'MERGE'];
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
trimmedQuery.includes(keyword)
);
if (hasDangerousKeyword) {
console.log("보안 오류: 위험한 키워드 포함 쿼리 시도:", { id, query: query.substring(0, 100) });
return {
success: false,
message: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
};
}
// 연결 정보 조회
console.log("연결 정보 조회 시작:", { id });
const connection = await queryOne<any>(

View File

@ -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();

View File

@ -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); // 최대 길이 제한
}
}

View File

@ -0,0 +1,264 @@
/**
* ( )
* Nodemailer를
*/
import nodemailer from 'nodemailer';
import { mailAccountFileService } from './mailAccountFileService';
import { mailTemplateFileService } from './mailTemplateFileService';
import { encryptionService } from './encryptionService';
export interface SendMailRequest {
accountId: string;
templateId?: string;
to: string[]; // 수신자 이메일 배열
subject: string;
variables?: Record<string, string>; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
}
export interface SendMailResult {
success: boolean;
messageId?: string;
accepted?: string[];
rejected?: string[];
error?: string;
}
class MailSendSimpleService {
/**
*
*/
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
try {
// 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
// 2. 계정 활성화 확인
if (account.status !== 'active') {
throw new Error('비활성 상태의 계정입니다.');
}
// 3. HTML 생성 (템플릿 또는 커스텀)
let htmlContent = request.customHtml || '';
if (!htmlContent && request.templateId) {
const template = await mailTemplateFileService.getTemplateById(request.templateId);
if (!template) {
throw new Error('템플릿을 찾을 수 없습니다.');
}
htmlContent = this.renderTemplate(template, request.variables);
}
if (!htmlContent) {
throw new Error('메일 내용이 없습니다.');
}
// 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
console.log('🔐 비밀번호 복호화 완료');
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 5. SMTP 연결 생성
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
console.log('📧 SMTP 연결 설정:', {
host: account.smtpHost,
port: account.smtpPort,
secure: isSecure,
user: account.smtpUsername,
});
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: isSecure, // SSL/TLS (포트 465는 자동으로 true)
auth: {
user: account.smtpUsername,
pass: decryptedPassword, // 복호화된 비밀번호 사용
},
// 타임아웃 설정 (30초)
connectionTimeout: 30000,
greetingTimeout: 30000,
});
console.log('📧 메일 발송 시도 중...');
// 6. 메일 발송
const info = await transporter.sendMail({
from: `"${account.name}" <${account.email}>`,
to: request.to.join(', '),
subject: this.replaceVariables(request.subject, request.variables),
html: htmlContent,
});
console.log('✅ 메일 발송 성공:', {
messageId: info.messageId,
accepted: info.accepted,
rejected: info.rejected,
});
return {
success: true,
messageId: info.messageId,
accepted: info.accepted as string[],
rejected: info.rejected as string[],
};
} catch (error) {
const err = error as Error;
console.error('❌ 메일 발송 실패:', err.message);
console.error('❌ 에러 상세:', err);
return {
success: false,
error: err.message,
};
}
}
/**
* 릿 ( )
*/
private renderTemplate(
template: any,
variables?: Record<string, string>
): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
template.components.forEach((component: any) => {
switch (component.type) {
case 'text':
let content = component.content || '';
if (variables) {
content = this.replaceVariables(content, variables);
}
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
break;
case 'button':
let buttonText = component.text || 'Button';
if (variables) {
buttonText = this.replaceVariables(buttonText, variables);
}
html += `
<a href="${component.url || '#'}" style="
display: inline-block;
padding: 12px 24px;
background-color: ${component.styles?.backgroundColor || '#007bff'};
color: ${component.styles?.color || 'white'};
text-decoration: none;
border-radius: 4px;
${this.styleObjectToString(component.styles)}
">${buttonText}</a>
`;
break;
case 'image':
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
break;
case 'spacer':
html += `<div style="height: ${component.height || 20}px;"></div>`;
break;
}
});
html += '</div>';
return html;
}
/**
*
*/
private replaceVariables(text: string, variables?: Record<string, string>): string {
if (!variables) return text;
let result = text;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, value);
});
return result;
}
/**
* CSS
*/
private styleObjectToString(styles?: Record<string, string>): string {
if (!styles) return '';
return Object.entries(styles)
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
.join('; ');
}
/**
* camelCase를 kebab-case로
*/
private camelToKebab(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/**
* SMTP
*/
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
try {
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('계정을 찾을 수 없습니다.');
}
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
console.log('🔐 비밀번호 복호화 완료');
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
console.log('🔌 SMTP 연결 설정:', {
host: account.smtpHost,
port: account.smtpPort,
secure: isSecure,
user: account.smtpUsername,
});
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: isSecure,
auth: {
user: account.smtpUsername,
pass: decryptedPassword, // 복호화된 비밀번호 사용
},
connectionTimeout: 10000, // 10초 타임아웃
greetingTimeout: 10000,
});
console.log('🔌 SMTP 연결 검증 중...');
await transporter.verify();
console.log('✅ SMTP 연결 검증 성공!');
return {
success: true,
message: 'SMTP 연결 성공!',
};
} catch (error) {
const err = error as Error;
console.error('❌ SMTP 연결 실패:', err.message);
return {
success: false,
message: `연결 실패: ${err.message}`,
};
}
}
}
export const mailSendSimpleService = new MailSendSimpleService();

View File

@ -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();

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
# Base image (Debian-based for glibc + OpenSSL compatibility)
FROM node:20-bookworm-slim AS base
WORKDIR /app
ENV NODE_ENV=production
# Install OpenSSL, curl (for healthcheck), and required certs
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Dependencies stage (install production dependencies)
FROM base AS deps
COPY package*.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
# Build stage (compile TypeScript)
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Runtime image
FROM base AS runner
ENV NODE_ENV=production
# Copy production node_modules
COPY --from=deps /app/node_modules ./node_modules
# Copy built files
COPY --from=build /app/dist ./dist
# Copy package files
COPY package*.json ./
# Create logs and uploads directories and set permissions (use existing node user with UID 1000)
RUN mkdir -p logs uploads && chown -R node:node logs uploads && chmod -R 755 logs uploads
EXPOSE 3001
USER node
CMD ["node", "dist/app.js"]

View File

@ -0,0 +1,60 @@
version: "3.8"
services:
# Node.js 백엔드
backend:
build:
context: ../../backend-node
dockerfile: ../docker/deploy/backend.Dockerfile
container_name: pms-backend-prod
restart: always
environment:
NODE_ENV: production
PORT: "3001"
HOST: 0.0.0.0
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
JWT_EXPIRES_IN: 24h
CORS_ORIGIN: https://v1.vexplor.com
CORS_CREDENTIALS: "true"
LOG_LEVEL: info
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
volumes:
- /home/vexplor/backend_data:/app/uploads
labels:
- traefik.enable=true
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
- traefik.http.routers.backend.entrypoints=websecure,web
- traefik.http.routers.backend.tls=true
- traefik.http.routers.backend.tls.certresolver=le
- traefik.http.services.backend.loadbalancer.server.port=3001
# Next.js 프론트엔드
frontend:
build:
context: ../../frontend
dockerfile: ../docker/deploy/frontend.Dockerfile
args:
- NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
container_name: pms-frontend-prod
restart: always
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.vexplor.com/api
NEXT_TELEMETRY_DISABLED: "1"
PORT: "3000"
HOSTNAME: 0.0.0.0
volumes:
- /home/vexplor/frontend_data:/app/data
labels:
- traefik.enable=true
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
- traefik.http.routers.frontend.entrypoints=websecure,web
- traefik.http.routers.frontend.tls=true
- traefik.http.routers.frontend.tls.certresolver=le
- traefik.http.services.frontend.loadbalancer.server.port=3000
networks:
default:
name: toktork_server_default
external: true

View File

@ -0,0 +1,59 @@
# Multi-stage build for Next.js
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable telemetry during the build
ENV NEXT_TELEMETRY_DISABLED 1
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Build the application
ENV DISABLE_ESLINT_PLUGIN=true
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the Next.js build output
COPY --from=builder /app/public ./public
# Production 모드에서는 .next 폴더 전체를 복사
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
# node_modules 복사 (production dependencies)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Next.js start 명령어 사용
CMD ["npm", "start"]

View File

@ -18,6 +18,7 @@ services:
- CORS_ORIGIN=http://localhost:9771
- CORS_CREDENTIALS=true
- LOG_LEVEL=debug
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
volumes:
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
- /app/node_modules

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,225 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react";
import {
MailAccount,
getMailAccounts,
createMailAccount,
updateMailAccount,
deleteMailAccount,
testMailAccountConnection,
CreateMailAccountDto,
UpdateMailAccountDto,
} from "@/lib/api/mail";
import MailAccountModal from "@/components/mail/MailAccountModal";
import MailAccountTable from "@/components/mail/MailAccountTable";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailAccountsPage() {
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
const loadAccounts = async () => {
setLoading(true);
try {
const data = await getMailAccounts();
// 배열인지 확인하고 설정
if (Array.isArray(data)) {
setAccounts(data);
} else {
console.error('API 응답이 배열이 아닙니다:', data);
setAccounts([]);
}
} catch (error) {
console.error('계정 로드 실패:', error);
setAccounts([]); // 에러 시 빈 배열로 설정
// alert('계정 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAccounts();
}, []);
const handleOpenCreateModal = () => {
setModalMode('create');
setSelectedAccount(null);
setIsModalOpen(true);
};
const handleOpenEditModal = (account: MailAccount) => {
setModalMode('edit');
setSelectedAccount(account);
setIsModalOpen(true);
};
const handleOpenDeleteModal = (account: MailAccount) => {
setSelectedAccount(account);
setIsDeleteModalOpen(true);
};
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
try {
if (modalMode === 'create') {
await createMailAccount(data as CreateMailAccountDto);
} else if (modalMode === 'edit' && selectedAccount) {
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
}
await loadAccounts();
setIsModalOpen(false);
} catch (error) {
throw error; // 모달에서 에러 처리
}
};
const handleDeleteAccount = async () => {
if (!selectedAccount) return;
try {
await deleteMailAccount(selectedAccount.id);
await loadAccounts();
alert('계정이 삭제되었습니다.');
} catch (error) {
console.error('계정 삭제 실패:', error);
alert('계정 삭제에 실패했습니다.');
}
};
const handleToggleStatus = async (account: MailAccount) => {
try {
const newStatus = account.status === 'active' ? 'inactive' : 'active';
await updateMailAccount(account.id, { status: newStatus });
await loadAccounts();
} catch (error) {
console.error('상태 변경 실패:', error);
alert('상태 변경에 실패했습니다.');
}
};
const handleTestConnection = async (account: MailAccount) => {
try {
setLoading(true);
const result = await testMailAccountConnection(account.id);
if (result.success) {
alert(`✅ SMTP 연결 성공!\n\n${result.message || '정상적으로 연결되었습니다.'}`);
} else {
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
}
} catch (error: any) {
console.error('연결 테스트 실패:', error);
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600">SMTP </p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadAccounts}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button
className="bg-orange-500 hover:bg-orange-600"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 메인 컨텐츠 */}
{loading ? (
<Card className="shadow-sm">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<CardContent className="p-6">
<MailAccountTable
accounts={accounts}
onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteModal}
onToggleStatus={handleToggleStatus}
onTestConnection={handleTestConnection}
/>
</CardContent>
</Card>
)}
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Mail className="w-5 h-5 mr-2 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
💡 SMTP !
</p>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span>Gmail, Naver, SMTP </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> </span>
</li>
</ul>
</CardContent>
</Card>
</div>
{/* 모달들 */}
<MailAccountModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveAccount}
account={selectedAccount}
mode={modalMode}
/>
<ConfirmDeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteAccount}
title="메일 계정 삭제"
message="이 메일 계정을 삭제하시겠습니까?"
itemName={selectedAccount?.name}
/>
</div>
);
}

View File

@ -0,0 +1,282 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Mail,
Send,
Inbox,
FileText,
RefreshCw,
TrendingUp,
Users,
Calendar,
Clock
} from "lucide-react";
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail";
interface DashboardStats {
totalAccounts: number;
totalTemplates: number;
sentToday: number;
receivedToday: number;
sentThisMonth: number;
successRate: number;
}
export default function MailDashboardPage() {
const [stats, setStats] = useState<DashboardStats>({
totalAccounts: 0,
totalTemplates: 0,
sentToday: 0,
receivedToday: 0,
sentThisMonth: 0,
successRate: 0,
});
const [loading, setLoading] = useState(false);
const loadStats = async () => {
setLoading(true);
try {
// 계정 수 (apiClient를 통해 토큰 포함)
const accounts = await getMailAccounts();
// 템플릿 수 (apiClient를 통해 토큰 포함)
const templates = await getMailTemplates();
setStats({
totalAccounts: accounts.length,
totalTemplates: templates.length,
sentToday: 0, // TODO: 실제 발송 통계 API 연동
receivedToday: 0,
sentThisMonth: 0,
successRate: 0,
});
} catch (error) {
// console.error('통계 로드 실패:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStats();
}, []);
const statCards = [
{
title: "등록된 계정",
value: stats.totalAccounts,
icon: Users,
color: "blue",
bgColor: "bg-blue-100",
iconColor: "text-blue-500",
},
{
title: "템플릿 수",
value: stats.totalTemplates,
icon: FileText,
color: "green",
bgColor: "bg-green-100",
iconColor: "text-green-500",
},
{
title: "오늘 발송",
value: stats.sentToday,
icon: Send,
color: "orange",
bgColor: "bg-orange-100",
iconColor: "text-orange-500",
},
{
title: "오늘 수신",
value: stats.receivedToday,
icon: Inbox,
color: "purple",
bgColor: "bg-purple-100",
iconColor: "text-purple-500",
},
];
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((stat, index) => (
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 mb-1">{stat.title}</p>
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`${stat.bgColor} p-3 rounded-lg`}>
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle className="flex items-center">
<Calendar className="w-5 h-5 mr-2 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<span className="text-lg font-semibold text-gray-900">
{stats.sentThisMonth}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<span className="text-lg font-semibold text-green-600">
{stats.successRate}%
</span>
</div>
<div className="pt-4 border-t">
<div className="flex items-center text-sm text-gray-500">
<TrendingUp className="w-4 h-4 mr-2 text-green-500" />
12%
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle className="flex items-center">
<Clock className="w-5 h-5 mr-2 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-3">
<div className="text-center text-gray-500 py-8">
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm"> </p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 빠른 액세스 */}
<Card className="shadow-sm">
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href="/admin/mail/accounts"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Users className="w-8 h-8 text-blue-500 mr-3" />
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
<a
href="/admin/mail/templates"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<FileText className="w-8 h-8 text-green-500 mr-3" />
<div>
<p className="font-medium text-gray-900">릿 </p>
<p className="text-sm text-gray-500">릿 </p>
</div>
</a>
<a
href="/admin/mail/send"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Send className="w-8 h-8 text-orange-500 mr-3" />
<div>
<p className="font-medium text-gray-900"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
<a
href="/admin/mail/receive"
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
>
<Inbox className="w-8 h-8 text-purple-500 mr-3" />
<div>
<p className="font-medium text-gray-900"></p>
<p className="text-sm text-gray-500"> </p>
</div>
</a>
</div>
</CardContent>
</Card>
{/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
<CardHeader>
<CardTitle className="text-lg flex items-center">
<Mail className="w-5 h-5 mr-2 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
💡 !
</p>
<ul className="space-y-2 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span>SMTP </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> 릿 </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> SQL </span>
</li>
<li className="flex items-start">
<span className="text-orange-500 mr-2"></span>
<span> </span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -83,7 +83,7 @@ export default function ScreenManagementPage() {
<div className="space-y-8">
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToNextStep("design")}>
<Button variant="default" className="shadow-sm" onClick={() => goToNextStep("design")}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
@ -121,7 +121,7 @@ export default function ScreenManagementPage() {
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToStep("list")}>
<Button variant="default" className="shadow-sm" onClick={() => goToStep("list")}>
</Button>
</div>

View File

@ -103,7 +103,7 @@ export default function TableManagementPage() {
setUiTexts(response.data.data);
}
} catch (error) {
console.error("다국어 텍스트 로드 실패:", error);
// console.error("다국어 텍스트 로드 실패:", error);
}
};
@ -125,20 +125,20 @@ export default function TableManagementPage() {
// 이미 로드된 경우이지만 빈 배열이 아닌 경우만 스킵
const existingColumns = referenceTableColumns[tableName];
if (existingColumns && existingColumns.length > 0) {
console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
// console.log(`🎯 참조 테이블 컬럼 이미 로드됨: ${tableName}`, existingColumns);
return;
}
console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
// console.log(`🎯 참조 테이블 컬럼 로드 시작: ${tableName}`);
try {
const result = await entityJoinApi.getReferenceTableColumns(tableName);
console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
// console.log(`🎯 참조 테이블 컬럼 로드 성공: ${tableName}`, result.columns);
setReferenceTableColumns((prev) => ({
...prev,
[tableName]: result.columns,
}));
} catch (error) {
console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
// console.error(`참조 테이블 컬럼 로드 실패: ${tableName}`, error);
setReferenceTableColumns((prev) => ({
...prev,
[tableName]: [],
@ -177,24 +177,24 @@ export default function TableManagementPage() {
const loadCommonCodeCategories = async () => {
try {
const response = await commonCodeApi.categories.getList({ isActive: true });
console.log("🔍 공통코드 카테고리 API 응답:", response);
// console.log("🔍 공통코드 카테고리 API 응답:", response);
if (response.success && response.data) {
console.log("📋 공통코드 카테고리 데이터:", response.data);
// console.log("📋 공통코드 카테고리 데이터:", response.data);
const categories = response.data.map((category) => {
console.log("🏷️ 카테고리 항목:", category);
// console.log("🏷️ 카테고리 항목:", category);
return {
value: category.category_code,
label: category.category_name || category.category_code,
};
});
console.log("✅ 매핑된 카테고리 옵션:", categories);
// console.log("✅ 매핑된 카테고리 옵션:", categories);
setCommonCodeCategories(categories);
}
} catch (error) {
console.error("공통코드 카테고리 로드 실패:", error);
// console.error("공통코드 카테고리 로드 실패:", error);
// 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능)
}
};
@ -213,7 +213,7 @@ export default function TableManagementPage() {
toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
// console.error("테이블 목록 로드 실패:", error);
toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
} finally {
setLoading(false);
@ -251,7 +251,7 @@ export default function TableManagementPage() {
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
}
} catch (error) {
console.error("컬럼 타입 정보 로드 실패:", error);
// console.error("컬럼 타입 정보 로드 실패:", error);
toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
} finally {
setColumnsLoading(false);
@ -411,7 +411,7 @@ export default function TableManagementPage() {
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
};
console.log("저장할 컬럼 설정:", columnSetting);
// console.log("저장할 컬럼 설정:", columnSetting);
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting,
@ -430,7 +430,7 @@ export default function TableManagementPage() {
toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
}
} catch (error) {
console.error("컬럼 설정 저장 실패:", error);
// console.error("컬럼 설정 저장 실패:", error);
toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
}
};
@ -448,7 +448,7 @@ export default function TableManagementPage() {
description: tableDescription,
});
} catch (error) {
console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
// console.warn("테이블 라벨 저장 실패 (API 미구현 가능):", error);
}
}
@ -467,7 +467,7 @@ export default function TableManagementPage() {
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
}));
console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
// console.log("저장할 전체 설정:", { tableLabel, tableDescription, columnSettings });
// 전체 테이블 설정을 한 번에 저장
const response = await apiClient.post(
@ -492,7 +492,7 @@ export default function TableManagementPage() {
}
}
} catch (error) {
console.error("설정 저장 실패:", error);
// console.error("설정 저장 실패:", error);
toast.error("설정 저장 중 오류가 발생했습니다.");
}
};
@ -525,7 +525,7 @@ export default function TableManagementPage() {
entityColumns.forEach((col) => {
if (col.referenceTable) {
console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
// console.log(`🎯 기존 Entity 컬럼 발견, 참조 테이블 컬럼 로드: ${col.columnName} -> ${col.referenceTable}`);
loadReferenceTableColumns(col.referenceTable);
}
});

View File

@ -54,7 +54,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: true,
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,
@ -72,7 +72,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: true,
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,
@ -94,7 +94,7 @@ const TEST_COMPONENTS: ComponentData[] = [
},
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,
@ -112,7 +112,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: false,
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,
@ -130,7 +130,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: false,
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,
@ -152,7 +152,7 @@ const TEST_COMPONENTS: ComponentData[] = [
},
style: {
labelFontSize: "14px",
labelColor: "#3b83f6",
labelColor: "#212121",
labelFontWeight: "500",
},
} as WidgetComponent,

View File

@ -9,13 +9,13 @@ import { Badge } from "@/components/ui/badge";
*/
export default function MainPage() {
return (
<div className="pt-10 space-y-6">
<div className="space-y-6 pt-10">
{/* 메인 컨텐츠 */}
{/* Welcome Message */}
<Card>
<CardContent className="pt-6">
<div className="space-y-6 text-center">
<h3 className="text-lg font-semibold">PLM !</h3>
<h3 className="text-lg font-semibold">Vexolor !</h3>
<p className="text-muted-foreground"> .</p>
<div className="flex justify-center space-x-2">
<Badge variant="secondary">Spring Boot</Badge>

View File

@ -152,7 +152,7 @@ export default function ScreenViewPage() {
const screenHeight = layout?.screenResolution?.height || 800;
return (
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 pt-10">
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 p-10">
{layout && layout.components.length > 0 ? (
// 캔버스 컴포넌트들을 정확한 해상도로 표시
<div
@ -162,7 +162,6 @@ export default function ScreenViewPage() {
height: `${screenHeight}px`,
minWidth: `${screenWidth}px`,
minHeight: `${screenHeight}px`,
margin: "0 auto 40px auto", // 하단 여백 추가
}}
>
{layout.components
@ -243,7 +242,7 @@ export default function ScreenViewPage() {
const labelText = component.style?.labelText || component.label || "";
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#3b83f6",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
@ -383,7 +382,7 @@ export default function ScreenViewPage() {
) : (
// 빈 화면일 때도 깔끔하게 표시
<div
className="mx-auto flex items-center justify-center bg-white"
className="mx-auto flex items-center justify-center bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,

View File

@ -76,6 +76,15 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Z-Index 계층 구조 */
--z-background: 1;
--z-layout: 10;
--z-content: 50;
--z-floating: 100;
--z-modal: 1000;
--z-tooltip: 2000;
--z-critical: 3000;
}
.dark {

View File

@ -48,6 +48,8 @@ export default function RootLayout({
<Toaster position="top-right" richColors />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}
<div id="portal-root" data-radix-portal="true" />
</div>
</body>
</html>

View File

@ -56,10 +56,10 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
// 파일 아이콘 가져오기
const getFileIcon = (fileName: string, size: number = 16) => {
const extension = fileName.split('.').pop()?.toLowerCase() || '';
const iconProps = { size, className: "text-gray-600" };
const iconProps = { size, className: "text-muted-foreground" };
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
return <Image {...iconProps} className="text-blue-600" />;
return <Image {...iconProps} className="text-primary" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
return <Video {...iconProps} className="text-purple-600" />;
@ -71,7 +71,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
return <Archive {...iconProps} className="text-yellow-600" />;
}
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
return <FileText {...iconProps} className="text-red-600" />;
return <FileText {...iconProps} className="text-destructive" />;
}
return <File {...iconProps} />;
};
@ -272,7 +272,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
variant="ghost"
size="sm"
onClick={() => handleRemove(file)}
className="flex items-center gap-1 text-red-600 hover:text-red-700"
className="flex items-center gap-1 text-destructive hover:text-red-700"
>
<Trash2 className="w-3 h-3" />
</Button>

View File

@ -175,7 +175,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
toast.error(result.error?.details || result.message);
}
} catch (error: any) {
console.error("컬럼 추가 실패:", error);
// console.error("컬럼 추가 실패:", error);
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
} finally {
setLoading(false);

View File

@ -179,7 +179,7 @@ export default function BatchJobModal({
const getStatusColor = (status: string) => {
switch (status) {
case 'Y': return 'bg-green-100 text-green-800';
case 'N': return 'bg-red-100 text-red-800';
case 'N': return 'bg-destructive/20 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
@ -314,29 +314,29 @@ export default function BatchJobModal({
<div className="grid grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-blue-600">
<div className="text-2xl font-bold text-primary">
{formData.execution_count || 0}
</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-green-600">
{formData.success_count || 0}
</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold text-red-600">
<div className="text-2xl font-bold text-destructive">
{formData.failure_count || 0}
</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
</div>
{formData.last_executed_at && (
<div className="text-sm text-gray-600">
<div className="text-sm text-muted-foreground">
: {new Date(formData.last_executed_at).toLocaleString()}
</div>
)}

View File

@ -55,7 +55,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
"cursor-pointer transition-colors",
category.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
@ -71,7 +71,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
{category.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{category.category_code}</p>
<p className="mt-1 text-sm text-muted-foreground">{category.category_code}</p>
{category.description && <p className="mt-1 text-sm text-gray-500">{category.description}</p>}
</div>

View File

@ -180,11 +180,11 @@ export function CodeCategoryFormModal({
{...createForm.register("categoryCode")}
disabled={isLoading}
placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
className={createForm.formState.errors.categoryCode ? "border-destructive" : ""}
onBlur={() => handleFieldBlur("categoryCode")}
/>
{createForm.formState.errors.categoryCode && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
<p className="text-sm text-destructive">{createForm.formState.errors.categoryCode.message}</p>
)}
{!createForm.formState.errors.categoryCode && (
<ValidationMessage
@ -200,7 +200,7 @@ export function CodeCategoryFormModal({
{isEditing && editingCategory && (
<div className="space-y-2">
<Label htmlFor="categoryCodeDisplay"> </Label>
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-gray-50" />
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-muted" />
<p className="text-sm text-gray-500"> .</p>
</div>
)}
@ -216,20 +216,20 @@ export function CodeCategoryFormModal({
className={
isEditing
? updateForm.formState.errors.categoryName
? "border-red-500"
? "border-destructive"
: ""
: createForm.formState.errors.categoryName
? "border-red-500"
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("categoryName")}
/>
{isEditing
? updateForm.formState.errors.categoryName && (
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryName.message}</p>
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p>
)
: createForm.formState.errors.categoryName && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryName.message}</p>
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p>
)}
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
<ValidationMessage
@ -251,20 +251,20 @@ export function CodeCategoryFormModal({
className={
isEditing
? updateForm.formState.errors.categoryNameEng
? "border-red-500"
? "border-destructive"
: ""
: createForm.formState.errors.categoryNameEng
? "border-red-500"
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("categoryNameEng")}
/>
{isEditing
? updateForm.formState.errors.categoryNameEng && (
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryNameEng.message}</p>
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p>
)
: createForm.formState.errors.categoryNameEng && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryNameEng.message}</p>
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p>
)}
{!(isEditing
? updateForm.formState.errors.categoryNameEng
@ -289,20 +289,20 @@ export function CodeCategoryFormModal({
className={
isEditing
? updateForm.formState.errors.description
? "border-red-500"
? "border-destructive"
: ""
: createForm.formState.errors.description
? "border-red-500"
? "border-destructive"
: ""
}
onBlur={() => handleFieldBlur("description")}
/>
{isEditing
? updateForm.formState.errors.description && (
<p className="text-sm text-red-600">{updateForm.formState.errors.description.message}</p>
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p>
)
: createForm.formState.errors.description && (
<p className="text-sm text-red-600">{createForm.formState.errors.description.message}</p>
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p>
)}
</div>
@ -320,19 +320,19 @@ export function CodeCategoryFormModal({
className={
isEditing
? updateForm.formState.errors.sortOrder
? "border-red-500"
? "border-destructive"
: ""
: createForm.formState.errors.sortOrder
? "border-red-500"
? "border-destructive"
: ""
}
/>
{isEditing
? updateForm.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{updateForm.formState.errors.sortOrder.message}</p>
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p>
)
: createForm.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{createForm.formState.errors.sortOrder.message}</p>
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p>
)}
</div>

View File

@ -82,7 +82,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<p className="text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
@ -116,7 +116,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnly" className="text-sm text-gray-600">
<label htmlFor="activeOnly" className="text-sm text-muted-foreground">
</label>
</div>

View File

@ -121,7 +121,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<p className="text-destructive"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
@ -155,7 +155,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnlyCodes" className="text-sm text-gray-600">
<label htmlFor="activeOnlyCodes" className="text-sm text-muted-foreground">
</label>
</div>
@ -221,13 +221,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
"transition-colors",
activeCode.isActive === "Y" || activeCode.is_active === "Y"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600",
: "bg-gray-100 text-muted-foreground",
)}
>
{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-muted-foreground">
{activeCode.codeValue || activeCode.code_value}
</p>
{activeCode.description && (

View File

@ -168,7 +168,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{...form.register("codeValue")}
disabled={isLoading || isEditing} // 수정 시에는 비활성화
placeholder="코드값을 입력하세요"
className={(form.formState.errors as any)?.codeValue ? "border-red-500" : ""}
className={(form.formState.errors as any)?.codeValue ? "border-destructive" : ""}
onBlur={(e) => {
const value = e.target.value.trim();
if (value && !isEditing) {
@ -180,7 +180,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{(form.formState.errors as any)?.codeValue && (
<p className="text-sm text-red-600">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
<p className="text-sm text-destructive">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
)}
{!isEditing && !(form.formState.errors as any)?.codeValue && (
<ValidationMessage
@ -199,7 +199,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{...form.register("codeName")}
disabled={isLoading}
placeholder="코드명을 입력하세요"
className={form.formState.errors.codeName ? "border-red-500" : ""}
className={form.formState.errors.codeName ? "border-destructive" : ""}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) {
@ -211,7 +211,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{form.formState.errors.codeName && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeName)}</p>
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeName)}</p>
)}
{!form.formState.errors.codeName && (
<ValidationMessage
@ -230,7 +230,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{...form.register("codeNameEng")}
disabled={isLoading}
placeholder="코드 영문명을 입력하세요"
className={form.formState.errors.codeNameEng ? "border-red-500" : ""}
className={form.formState.errors.codeNameEng ? "border-destructive" : ""}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) {
@ -242,7 +242,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
}}
/>
{form.formState.errors.codeNameEng && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
)}
{!form.formState.errors.codeNameEng && (
<ValidationMessage
@ -262,10 +262,10 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={form.formState.errors.description ? "border-red-500" : ""}
className={form.formState.errors.description ? "border-destructive" : ""}
/>
{form.formState.errors.description && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.description)}</p>
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.description)}</p>
)}
</div>
@ -278,10 +278,10 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
className={form.formState.errors.sortOrder ? "border-destructive" : ""}
/>
{form.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.sortOrder)}</p>
<p className="text-sm text-destructive">{getErrorMessage(form.formState.errors.sortOrder)}</p>
)}
</div>

View File

@ -188,7 +188,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
const hasRowError = rowErrors.length > 0;
return (
<TableRow key={index} className={hasRowError ? "bg-red-50" : ""}>
<TableRow key={index} className={hasRowError ? "bg-destructive/10" : ""}>
<TableCell>
<div className="space-y-1">
<Input
@ -199,7 +199,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
className={hasRowError ? "border-red-300" : ""}
/>
{rowErrors.length > 0 && (
<div className="space-y-1 text-xs text-red-600">
<div className="space-y-1 text-xs text-destructive">
{rowErrors.map((error, i) => (
<div key={i}>{error}</div>
))}

View File

@ -43,7 +43,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
);
};
// 상태에 따른 Badge 색상 결정
console.log(companies);
// console.log(companies);
// 로딩 상태 렌더링
if (isLoading) {
return (

View File

@ -172,7 +172,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
toast.error("검증 실패. 오류를 확인해주세요.");
}
} catch (error: any) {
console.error("테이블 검증 실패:", error);
// console.error("테이블 검증 실패:", error);
toast.error("검증 중 오류가 발생했습니다.");
} finally {
setValidating(false);
@ -210,7 +210,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
toast.error(result.error?.details || result.message);
}
} catch (error: any) {
console.error("테이블 생성 실패:", error);
// console.error("테이블 생성 실패:", error);
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
} finally {
setLoading(false);
@ -248,7 +248,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
placeholder="예: customer_info"
className={tableNameError ? "border-red-300" : ""}
/>
{tableNameError && <p className="text-sm text-red-600">{tableNameError}</p>}
{tableNameError && <p className="text-sm text-destructive">{tableNameError}</p>}
<p className="text-muted-foreground text-xs"> , // </p>
</div>

View File

@ -64,7 +64,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
setLogs(logsResult.logs);
setStatistics(statsResult);
} catch (error) {
console.error("DDL 로그 로드 실패:", error);
// console.error("DDL 로그 로드 실패:", error);
toast.error("DDL 로그를 불러오는데 실패했습니다.");
} finally {
if (showLoading) setLoading(false);
@ -101,7 +101,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
loadData(false);
} catch (error) {
console.error("로그 정리 실패:", error);
// console.error("로그 정리 실패:", error);
toast.error("로그 정리에 실패했습니다.");
}
};
@ -271,14 +271,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{log.success ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
<XCircle className="h-4 w-4 text-destructive" />
)}
<span className={log.success ? "text-green-600" : "text-red-600"}>
<span className={log.success ? "text-green-600" : "text-destructive"}>
{log.success ? "성공" : "실패"}
</span>
</div>
{log.error_message && (
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{log.error_message}</div>
<div className="mt-1 max-w-xs truncate text-xs text-destructive">{log.error_message}</div>
)}
</TableCell>
@ -325,7 +325,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{statistics.failedExecutions}</div>
<div className="text-2xl font-bold text-destructive">{statistics.failedExecutions}</div>
</CardContent>
</Card>
@ -374,13 +374,13 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{statistics.recentFailures.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base text-red-600"> </CardTitle>
<CardTitle className="text-base text-destructive"> </CardTitle>
<CardDescription> DDL .</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{statistics.recentFailures.map((failure, index) => (
<div key={index} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div key={index} className="rounded-lg border border-destructive/20 bg-destructive/10 p-3">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
@ -390,7 +390,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
</span>
</div>
<div className="text-sm text-red-600">{failure.error_message}</div>
<div className="text-sm text-destructive">{failure.error_message}</div>
</div>
))}
</div>

View File

@ -120,7 +120,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
<div
className={`h-2 rounded-full transition-all duration-300 ${
summary.totalSizeMB > 1000 ? "bg-red-500" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
summary.totalSizeMB > 1000 ? "bg-destructive/100" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
}`}
style={{
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,

View File

@ -450,7 +450,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
className={`rounded-md border p-3 text-sm ${
testResult.success
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
: "border-destructive/20 bg-destructive/10 text-red-800"
}`}
>
<div className="font-medium">{testResult.success ? "✅ 연결 성공" : "❌ 연결 실패"}</div>
@ -469,7 +469,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
{!testResult.success && testResult.error && (
<div className="mt-2 text-xs">
<div> : {testResult.error.code}</div>
{testResult.error.details && <div className="mt-1 text-red-600">{testResult.error.details}</div>}
{testResult.error.details && <div className="mt-1 text-destructive">{testResult.error.details}</div>}
</div>
)}
</div>

View File

@ -238,10 +238,10 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div className="mb-6 flex items-center justify-center">
<div className="flex items-center gap-4">
<div
className={`flex items-center gap-2 ${step === "basic" ? "text-blue-600" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
className={`flex items-center gap-2 ${step === "basic" ? "text-primary" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-blue-100 text-blue-600" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-primary/20 text-primary" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
>
1
</div>
@ -249,19 +249,19 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
</div>
<div className="h-px w-8 bg-gray-300" />
<div
className={`flex items-center gap-2 ${step === "template" ? "text-blue-600" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
className={`flex items-center gap-2 ${step === "template" ? "text-primary" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-blue-100 text-blue-600" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-primary/20 text-primary" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
>
2
</div>
<span className="text-sm font-medium">릿 </span>
</div>
<div className="h-px w-8 bg-gray-300" />
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-blue-600" : "text-gray-400"}`}>
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-primary" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-blue-100 text-blue-600" : "bg-gray-100"}`}
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-primary/20 text-primary" : "bg-gray-100"}`}
>
3
</div>
@ -304,13 +304,13 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<Card
key={category.id}
className={`cursor-pointer transition-all ${
formData.category === category.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
formData.category === category.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
}`}
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<IconComponent className="h-5 w-5 text-gray-600" />
<IconComponent className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
@ -346,7 +346,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<Card
key={template.id}
className={`cursor-pointer transition-all ${
formData.template === template.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
formData.template === template.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
}`}
onClick={() =>
setFormData((prev) => ({
@ -362,7 +362,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div className="font-medium">{template.name}</div>
<Badge variant="secondary">{template.zones} </Badge>
</div>
<div className="text-sm text-gray-600">{template.description}</div>
<div className="text-sm text-muted-foreground">{template.description}</div>
<div className="text-xs text-gray-500">: {template.example}</div>
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
</div>
@ -427,7 +427,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div className="space-y-4">
{generationResult ? (
<Alert
className={generationResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"}
>
<Info className="h-4 w-4" />
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
@ -479,7 +479,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div>
<strong> :</strong>
</div>
<ul className="ml-4 space-y-1 text-xs text-gray-600">
<ul className="ml-4 space-y-1 text-xs text-muted-foreground">
<li> {formData.name.toLowerCase()}/index.ts</li>
<li>
{formData.name.toLowerCase()}/{formData.name}Layout.tsx

View File

@ -46,21 +46,21 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
parentCompanyCode,
uiTexts,
}) => {
console.log("🎯 MenuFormModal 렌더링 - Props:", {
isOpen,
menuId,
parentId,
menuType,
level,
parentCompanyCode,
});
// console.log("🎯 MenuFormModal 렌더링 - Props:", {
// isOpen,
// menuId,
// parentId,
// menuType,
// level,
// parentCompanyCode,
// });
// 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => {
return uiTexts[key] || fallback || key;
};
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
// console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
const [formData, setFormData] = useState<MenuFormData>({
parentObjId: parentId || "0",
@ -93,20 +93,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
try {
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
console.log("🔍 화면 목록 로드 디버깅:", {
totalScreens: response.data.length,
firstScreen: response.data[0],
firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
allScreenIds: response.data
.map((s) => ({
screenId: s.screenId,
legacyId: s.id,
name: s.screenName,
code: s.screenCode,
}))
.slice(0, 5), // 처음 5개만 출력
});
// console.log("🔍 화면 목록 로드 디버깅:", {
// totalScreens: response.data.length,
// firstScreen: response.data[0],
// firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
// firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
// allScreenIds: response.data
// .map((s) => ({
// screenId: s.screenId,
// legacyId: s.id,
// name: s.screenName,
// code: s.screenCode,
// }))
// .slice(0, 5), // 처음 5개만 출력
// });
setScreens(response.data);
console.log("✅ 화면 목록 로드 완료:", response.data.length);
@ -118,14 +118,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
// 화면 선택 시 URL 자동 설정
const handleScreenSelect = (screen: ScreenDefinition) => {
console.log("🖥️ 화면 선택 디버깅:", {
screen,
screenId: screen.screenId,
screenIdType: typeof screen.screenId,
legacyId: screen.id,
allFields: Object.keys(screen),
screenValues: Object.values(screen),
});
// console.log("🖥️ 화면 선택 디버깅:", {
// screen,
// screenId: screen.screenId,
// screenIdType: typeof screen.screenId,
// legacyId: screen.id,
// allFields: Object.keys(screen),
// screenValues: Object.values(screen),
// });
// ScreenDefinition에서는 screenId 필드를 사용
const actualScreenId = screen.screenId || screen.id;
@ -154,26 +154,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
menuUrl: screenUrl,
}));
console.log("🖥️ 화면 선택 완료:", {
screenId: screen.screenId,
legacyId: screen.id,
actualScreenId,
screenName: screen.screenName,
menuType: menuType,
formDataMenuType: formData.menuType,
isAdminMenu,
generatedUrl: screenUrl,
});
// console.log("🖥️ 화면 선택 완료:", {
// screenId: screen.screenId,
// legacyId: screen.id,
// actualScreenId,
// screenName: screen.screenName,
// menuType: menuType,
// formDataMenuType: formData.menuType,
// isAdminMenu,
// generatedUrl: screenUrl,
// });
};
// URL 타입 변경 시 처리
const handleUrlTypeChange = (type: "direct" | "screen") => {
console.log("🔄 URL 타입 변경:", {
from: urlType,
to: type,
currentSelectedScreen: selectedScreen?.screenName,
currentUrl: formData.menuUrl,
});
// console.log("🔄 URL 타입 변경:", {
// from: urlType,
// to: type,
// currentSelectedScreen: selectedScreen?.screenName,
// currentUrl: formData.menuUrl,
// });
setUrlType(type);
@ -225,7 +225,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
try {
setLoading(true);
console.log("API 호출 시작 - menuId:", menuId);
console.log("API URL:", `/admin/menus/${menuId}`);
// console.log("API URL:", `/admin/menus/${menuId}`);
const response = await menuApi.getMenuInfo(menuId);
console.log("메뉴 정보 조회 응답:", response);
@ -285,29 +285,29 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
if (screenId) {
console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
menuUrl,
screenId,
hasAdminParam: menuUrl.includes("mode=admin"),
currentScreensCount: screens.length,
});
// console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
// menuUrl,
// screenId,
// hasAdminParam: menuUrl.includes("mode=admin"),
// currentScreensCount: screens.length,
// });
// 화면 설정 함수
const setScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
if (screen) {
setSelectedScreen(screen);
console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
screen,
originalUrl: menuUrl,
hasAdminParam: menuUrl.includes("mode=admin"),
});
// console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
// screen,
// originalUrl: menuUrl,
// hasAdminParam: menuUrl.includes("mode=admin"),
// });
return true;
} else {
console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
screenId,
availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
});
// console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
// screenId,
// availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
// });
return false;
}
};
@ -330,26 +330,26 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setSelectedScreen(null);
}
console.log("설정된 폼 데이터:", {
objid: menu.objid || menu.OBJID,
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
menuUrl: menu.menu_url || menu.MENU_URL || "",
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
seq: menu.seq || menu.SEQ || 1,
menuType: convertedMenuType,
status: convertedStatus,
companyCode: companyCode,
langKey: langKey,
});
// console.log("설정된 폼 데이터:", {
// objid: menu.objid || menu.OBJID,
// parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
// menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
// menuUrl: menu.menu_url || menu.MENU_URL || "",
// menuDesc: menu.menu_desc || menu.MENU_DESC || "",
// seq: menu.seq || menu.SEQ || 1,
// menuType: convertedMenuType,
// status: convertedStatus,
// companyCode: companyCode,
// langKey: langKey,
// });
}
} catch (error: any) {
console.error("메뉴 정보 로딩 오류:", error);
console.error("오류 상세 정보:", {
message: error?.message,
stack: error?.stack,
response: error?.response,
});
// console.error("오류 상세 정보:", {
// message: error?.message,
// stack: error?.stack,
// response: error?.response,
// });
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
} finally {
setLoading(false);
@ -390,13 +390,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
langKey: "", // 다국어 키 초기화
});
console.log("메뉴 등록 기본값 설정:", {
parentObjId: parentId || "0",
menuType: defaultMenuType,
status: "ACTIVE",
companyCode: "",
langKey: "",
});
// console.log("메뉴 등록 기본값 설정:", {
// parentObjId: parentId || "0",
// menuType: defaultMenuType,
// status: "ACTIVE",
// companyCode: "",
// langKey: "",
// });
}
}, [menuId, parentId, menuType]);
@ -448,11 +448,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
if (screen) {
setSelectedScreen(screen);
console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
screenId,
screenName: screen.screenName,
menuUrl,
});
// console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
// screenId,
// screenName: screen.screenName,
// menuUrl,
// });
}
}
}
@ -826,10 +826,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
{/* 선택된 화면 정보 표시 */}
{selectedScreen && (
<div className="rounded-md border bg-blue-50 p-3">
<div className="rounded-md border bg-accent p-3">
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
<div className="text-xs text-blue-600">: {selectedScreen.screenCode}</div>
<div className="text-xs text-blue-600"> URL: {formData.menuUrl}</div>
<div className="text-xs text-primary">: {selectedScreen.screenCode}</div>
<div className="text-xs text-primary"> URL: {formData.menuUrl}</div>
</div>
)}
</div>

View File

@ -195,7 +195,7 @@ export const MenuManagement: React.FC = () => {
defaultTexts[key] = defaultText;
});
setUiTexts(defaultTexts);
console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
// console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
};
// 기본 텍스트 반환 함수
@ -303,20 +303,20 @@ export const MenuManagement: React.FC = () => {
// uiTexts 상태 변경 감지
useEffect(() => {
console.log("🔄 uiTexts 상태 변경됨:", {
count: Object.keys(uiTexts).length,
sampleKeys: Object.keys(uiTexts).slice(0, 5),
sampleValues: Object.entries(uiTexts)
.slice(0, 3)
.map(([k, v]) => `${k}: ${v}`),
});
// console.log("🔄 uiTexts 상태 변경됨:", {
// count: Object.keys(uiTexts).length,
// sampleKeys: Object.keys(uiTexts).slice(0, 5),
// sampleValues: Object.entries(uiTexts)
// .slice(0, 3)
// .map(([k, v]) => `${k}: ${v}`),
// });
}, [uiTexts]);
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
useEffect(() => {
const timer = setTimeout(() => {
if (userLang && !uiTextsLoading) {
console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
// console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
loadUITexts();
}
}, 300); // 300ms 후 실행
@ -328,7 +328,7 @@ export const MenuManagement: React.FC = () => {
useEffect(() => {
const fallbackTimer = setTimeout(() => {
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
// console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
@ -378,15 +378,15 @@ export const MenuManagement: React.FC = () => {
}, [isCompanyDropdownOpen]);
const loadMenus = async (showLoading = true) => {
console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
try {
if (showLoading) {
setLoading(true);
}
await refreshMenus();
console.log("📋 메뉴 목록 조회 성공");
// console.log("📋 메뉴 목록 조회 성공");
} catch (error) {
console.error("❌ 메뉴 목록 조회 실패:", error);
// console.error("❌ 메뉴 목록 조회 실패:", error);
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {
@ -397,21 +397,21 @@ export const MenuManagement: React.FC = () => {
// 회사 목록 조회
const loadCompanies = async () => {
console.log("🏢 회사 목록 조회 시작");
// console.log("🏢 회사 목록 조회 시작");
try {
const response = await apiClient.get("/admin/companies");
if (response.data.success) {
console.log("🏢 회사 목록 응답:", response.data);
// console.log("🏢 회사 목록 응답:", response.data);
const companyList = response.data.data.map((company: any) => ({
code: company.company_code || company.companyCode,
name: company.company_name || company.companyName,
}));
console.log("🏢 변환된 회사 목록:", companyList);
// console.log("🏢 변환된 회사 목록:", companyList);
setCompanies(companyList);
}
} catch (error) {
console.error("❌ 회사 목록 조회 실패:", error);
// console.error("❌ 회사 목록 조회 실패:", error);
}
};
@ -421,7 +421,7 @@ export const MenuManagement: React.FC = () => {
// userLang이 설정되지 않았으면 기본값 설정
if (!userLang) {
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
// console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
@ -432,7 +432,7 @@ export const MenuManagement: React.FC = () => {
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
if (Object.keys(uiTexts).length === 0) {
console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
// console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key);
@ -440,14 +440,14 @@ export const MenuManagement: React.FC = () => {
setUiTexts(defaultTexts);
}
console.log("🌐 UI 다국어 텍스트 로드 시작", {
userLang,
apiParams: {
companyCode: "*",
menuCode: "menu.management",
userLang: userLang,
},
});
// console.log("🌐 UI 다국어 텍스트 로드 시작", {
// userLang,
// apiParams: {
// companyCode: "*",
// menuCode: "menu.management",
// userLang: userLang,
// },
// });
setUiTextsLoading(true);
try {
@ -467,28 +467,28 @@ export const MenuManagement: React.FC = () => {
if (response.data.success) {
const translations = response.data.data;
console.log("🌐 배치 다국어 텍스트 응답:", translations);
// console.log("🌐 배치 다국어 텍스트 응답:", translations);
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
const mergedTranslations = { ...uiTexts, ...translations };
console.log("🔧 setUiTexts 호출 전:", {
translationsCount: Object.keys(translations).length,
mergedCount: Object.keys(mergedTranslations).length,
});
// console.log("🔧 setUiTexts 호출 전:", {
// translationsCount: Object.keys(translations).length,
// mergedCount: Object.keys(mergedTranslations).length,
// });
setUiTexts(mergedTranslations);
console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
// console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
setTranslationCache(userLang, mergedTranslations);
} else {
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
// console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
// API 실패 시에도 기존 uiTexts는 유지
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
}
} catch (error) {
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
// console.error("❌ UI 다국어 텍스트 로드 실패:", error);
// API 실패 시에도 기존 uiTexts는 유지
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
} finally {
setUiTextsLoading(false);
}
@ -519,12 +519,12 @@ export const MenuManagement: React.FC = () => {
// 다국어 API 테스트 함수 (getUITextSync 사용)
const testMultiLangAPI = async () => {
console.log("🧪 다국어 API 테스트 시작");
// console.log("🧪 다국어 API 테스트 시작");
try {
const text = getUITextSync("menu.management.admin");
console.log("🧪 다국어 API 테스트 결과:", text);
// console.log("🧪 다국어 API 테스트 결과:", text);
} catch (error) {
console.error("❌ 다국어 API 테스트 실패:", error);
// console.error("❌ 다국어 API 테스트 실패:", error);
}
};
@ -576,14 +576,14 @@ export const MenuManagement: React.FC = () => {
};
const handleEditMenu = (menuId: string) => {
console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
// console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
// 현재 메뉴 정보 찾기
const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus;
const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
if (menuToEdit) {
console.log("수정할 메뉴 정보:", menuToEdit);
// console.log("수정할 메뉴 정보:", menuToEdit);
setFormData({
menuId: menuId,
@ -593,15 +593,15 @@ export const MenuManagement: React.FC = () => {
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
});
console.log("설정된 formData:", {
menuId: menuId,
parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
menuType: selectedMenuType,
level: 0,
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
});
// console.log("설정된 formData:", {
// menuId: menuId,
// parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
// menuType: selectedMenuType,
// level: 0,
// parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
// });
} else {
console.error("수정할 메뉴를 찾을 수 없음:", menuId);
// console.error("수정할 메뉴를 찾을 수 없음:", menuId);
}
setFormModalOpen(true);
@ -640,31 +640,31 @@ export const MenuManagement: React.FC = () => {
setDeleting(true);
try {
const menuIds = Array.from(selectedMenus);
console.log("삭제할 메뉴 IDs:", menuIds);
// console.log("삭제할 메뉴 IDs:", menuIds);
toast.info(getUITextSync("message.menu.delete.processing"));
const response = await menuApi.deleteMenusBatch(menuIds);
console.log("삭제 API 응답:", response);
console.log("응답 구조:", {
success: response.success,
data: response.data,
message: response.message,
});
// console.log("삭제 API 응답:", response);
// console.log("응답 구조:", {
// success: response.success,
// data: response.data,
// message: response.message,
// });
if (response.success && response.data) {
const { deletedCount, failedCount } = response.data;
console.log("삭제 결과:", { deletedCount, failedCount });
// console.log("삭제 결과:", { deletedCount, failedCount });
// 선택된 메뉴 초기화
setSelectedMenus(new Set());
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
console.log("메뉴 목록 새로고침 시작");
// console.log("메뉴 목록 새로고침 시작");
await loadMenus(false);
// 전역 메뉴 상태도 업데이트
await refreshMenus();
console.log("메뉴 목록 새로고침 완료");
// console.log("메뉴 목록 새로고침 완료");
// 삭제 결과 메시지
if (failedCount === 0) {
@ -678,11 +678,11 @@ export const MenuManagement: React.FC = () => {
);
}
} else {
console.error("삭제 실패:", response);
// console.error("삭제 실패:", response);
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
}
} catch (error) {
console.error("메뉴 삭제 중 오류:", error);
// console.error("메뉴 삭제 중 오류:", error);
toast.error(getUITextSync("message.menu.delete.failed"));
} finally {
setDeleting(false);
@ -718,7 +718,7 @@ export const MenuManagement: React.FC = () => {
toast.error(response.message);
}
} catch (error) {
console.error("메뉴 상태 토글 오류:", error);
// console.error("메뉴 상태 토글 오류:", error);
toast.error(getUITextSync("message.menu.status.toggle.failed"));
}
};
@ -785,15 +785,15 @@ export const MenuManagement: React.FC = () => {
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
// 디버깅을 위한 간단한 상태 표시
console.log("🔍 MenuManagement 렌더링 상태:", {
loading,
uiTextsLoading,
uiTextsCount,
adminMenusCount,
userMenusCount,
selectedMenuType,
userLang,
});
// console.log("🔍 MenuManagement 렌더링 상태:", {
// loading,
// uiTextsLoading,
// uiTextsCount,
// adminMenusCount,
// userMenusCount,
// selectedMenuType,
// userLang,
// });
if (loading) {
return (
@ -828,7 +828,7 @@ export const MenuManagement: React.FC = () => {
<CardContent className="space-y-3 pt-4">
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-gray-300"
}`}
onClick={() => handleMenuTypeChange("admin")}
>
@ -836,7 +836,7 @@ export const MenuManagement: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.admin.description")}
</p>
</div>
@ -849,7 +849,7 @@ export const MenuManagement: React.FC = () => {
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "user" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
}`}
onClick={() => handleMenuTypeChange("user")}
>
@ -857,7 +857,7 @@ export const MenuManagement: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-muted-foreground">
{getUITextSync("menu.management.user.description")}
</p>
</div>
@ -997,7 +997,7 @@ export const MenuManagement: React.FC = () => {
</div>
<div className="flex items-end">
<div className="text-sm text-gray-600">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
</div>
</div>
@ -1006,7 +1006,7 @@ export const MenuManagement: React.FC = () => {
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div>
<div className="flex space-x-2">

View File

@ -67,7 +67,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
const getLevelBadge = (level: number) => {
switch (level) {
case 0:
return "bg-blue-100 text-blue-800";
return "bg-primary/20 text-blue-800";
case 1:
return "bg-green-100 text-green-800";
case 2:
@ -239,7 +239,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
</div>
</TableCell>
<TableCell>{seq}</TableCell>
<TableCell className="text-sm text-gray-600">
<TableCell className="text-sm text-muted-foreground">
<div className="flex flex-col">
<span
className={`font-medium ${companyName && companyName !== getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
@ -253,12 +253,12 @@ export const MenuTable: React.FC<MenuTableProps> = ({
)}
</div>
</TableCell>
<TableCell className="text-left text-sm text-gray-600">
<TableCell className="text-left text-sm text-muted-foreground">
<div className="max-w-[200px]">
{menuUrl ? (
<div className="group relative">
<div
className={`cursor-pointer transition-colors hover:text-blue-600 ${
className={`cursor-pointer transition-colors hover:text-primary ${
menuUrl.length > 30 ? "truncate" : ""
}`}
onClick={() => {

View File

@ -74,8 +74,8 @@ export default function MonitoringDashboard() {
const getStatusBadge = (status: string) => {
const variants = {
completed: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
running: "bg-blue-100 text-blue-800",
failed: "bg-destructive/20 text-red-800",
running: "bg-primary/20 text-blue-800",
pending: "bg-yellow-100 text-yellow-800",
cancelled: "bg-gray-100 text-gray-800",
};
@ -129,7 +129,7 @@ export default function MonitoringDashboard() {
variant="outline"
size="sm"
onClick={toggleAutoRefresh}
className={autoRefresh ? "bg-blue-50 text-blue-600" : ""}
className={autoRefresh ? "bg-accent text-primary" : ""}
>
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
@ -167,7 +167,7 @@ export default function MonitoringDashboard() {
<div className="text-2xl">🔄</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{monitoring.running_jobs}</div>
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
<p className="text-xs text-muted-foreground">
</p>
@ -193,7 +193,7 @@ export default function MonitoringDashboard() {
<div className="text-2xl"></div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{monitoring.failed_jobs_today}</div>
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
<p className="text-xs text-muted-foreground">
</p>
@ -269,7 +269,7 @@ export default function MonitoringDashboard() {
</TableCell>
<TableCell className="max-w-xs">
{execution.error_message ? (
<span className="text-red-600 text-sm truncate block">
<span className="text-destructive text-sm truncate block">
{execution.error_message}
</span>
) : (

View File

@ -64,9 +64,9 @@ export default function MultiLangPage() {
// 회사 목록 조회
const fetchCompanies = async () => {
try {
console.log("회사 목록 조회 시작");
// console.log("회사 목록 조회 시작");
const response = await apiClient.get("/admin/companies");
console.log("회사 목록 응답 데이터:", response.data);
// console.log("회사 목록 응답 데이터:", response.data);
const data = response.data;
if (data.success) {
@ -74,13 +74,13 @@ export default function MultiLangPage() {
code: company.company_code,
name: company.company_name,
}));
console.log("변환된 회사 목록:", companyList);
// console.log("변환된 회사 목록:", companyList);
setCompanies(companyList);
} else {
console.error("회사 목록 조회 실패:", data.message);
// console.error("회사 목록 조회 실패:", data.message);
}
} catch (error) {
console.error("회사 목록 조회 실패:", error);
// console.error("회사 목록 조회 실패:", error);
}
};
@ -93,7 +93,7 @@ export default function MultiLangPage() {
setLanguages(data.data);
}
} catch (error) {
console.error("언어 목록 조회 실패:", error);
// console.error("언어 목록 조회 실패:", error);
}
};
@ -103,13 +103,13 @@ export default function MultiLangPage() {
const response = await apiClient.get("/multilang/keys");
const data = response.data;
if (data.success) {
console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
setLangKeys(data.data);
} else {
console.error("❌ 키 목록 로드 실패:", data.message);
// console.error("❌ 키 목록 로드 실패:", data.message);
}
} catch (error) {
console.error("다국어 키 목록 조회 실패:", error);
// console.error("다국어 키 목록 조회 실패:", error);
}
};
@ -146,25 +146,25 @@ export default function MultiLangPage() {
// 선택된 키의 다국어 텍스트 조회
const fetchLangTexts = async (keyId: number) => {
try {
console.log("다국어 텍스트 조회 시작: keyId =", keyId);
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
const data = response.data;
console.log("다국어 텍스트 조회 응답:", data);
// console.log("다국어 텍스트 조회 응답:", data);
if (data.success) {
setLangTexts(data.data);
// 편집용 텍스트 초기화
const editingData = data.data.map((text: LangText) => ({ ...text }));
setEditingTexts(editingData);
console.log("편집용 텍스트 설정:", editingData);
// console.log("편집용 텍스트 설정:", editingData);
}
} catch (error) {
console.error("다국어 텍스트 조회 실패:", error);
// console.error("다국어 텍스트 조회 실패:", error);
}
};
// 언어 키 선택 처리
const handleKeySelect = (key: LangKey) => {
console.log("언어 키 선택:", key);
// console.log("언어 키 선택:", key);
setSelectedKey(key);
fetchLangTexts(key.keyId);
};
@ -172,9 +172,9 @@ export default function MultiLangPage() {
// 디버깅용 useEffect
useEffect(() => {
if (selectedKey) {
console.log("선택된 키 변경:", selectedKey);
console.log("언어 목록:", languages);
console.log("편집 텍스트:", editingTexts);
// console.log("선택된 키 변경:", selectedKey);
// console.log("언어 목록:", languages);
// console.log("편집 텍스트:", editingTexts);
}
}, [selectedKey, languages, editingTexts]);
@ -222,7 +222,7 @@ export default function MultiLangPage() {
fetchLangTexts(selectedKey.keyId);
}
} catch (error) {
console.error("텍스트 저장 실패:", error);
// console.error("텍스트 저장 실패:", error);
alert("저장에 실패했습니다.");
}
};
@ -271,7 +271,7 @@ export default function MultiLangPage() {
alert(`오류: ${result.message}`);
}
} catch (error) {
console.error("언어 저장 중 오류:", error);
// console.error("언어 저장 중 오류:", error);
alert("언어 저장 중 오류가 발생했습니다.");
}
};
@ -307,7 +307,7 @@ export default function MultiLangPage() {
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
}
} catch (error) {
console.error("언어 삭제 중 오류:", error);
// console.error("언어 삭제 중 오류:", error);
alert("언어 삭제 중 오류가 발생했습니다.");
}
};
@ -369,7 +369,7 @@ export default function MultiLangPage() {
}
}
} catch (error) {
console.error("언어 키 저장 실패:", error);
// console.error("언어 키 저장 실패:", error);
alert("언어 키 저장에 실패했습니다.");
}
};
@ -397,7 +397,7 @@ export default function MultiLangPage() {
alert("상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("키 상태 토글 실패:", error);
// console.error("키 상태 토글 실패:", error);
alert("키 상태 변경 중 오류가 발생했습니다.");
}
};
@ -414,7 +414,7 @@ export default function MultiLangPage() {
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("언어 상태 토글 실패:", error);
// console.error("언어 상태 토글 실패:", error);
alert("언어 상태 변경 중 오류가 발생했습니다.");
}
};
@ -463,7 +463,7 @@ export default function MultiLangPage() {
alert("일부 키 삭제에 실패했습니다.");
}
} catch (error) {
console.error("선택된 키 삭제 실패:", error);
// console.error("선택된 키 삭제 실패:", error);
alert("선택된 키 삭제에 실패했습니다.");
}
};
@ -485,7 +485,7 @@ export default function MultiLangPage() {
}
}
} catch (error) {
console.error("언어 키 삭제 실패:", error);
// console.error("언어 키 삭제 실패:", error);
alert("언어 키 삭제에 실패했습니다.");
}
};
@ -673,7 +673,7 @@ export default function MultiLangPage() {
<button
onClick={() => setActiveTab("keys")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "keys" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
@ -681,7 +681,7 @@ export default function MultiLangPage() {
<button
onClick={() => setActiveTab("languages")}
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === "languages" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
@ -698,7 +698,7 @@ export default function MultiLangPage() {
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600"> {languages.length} .</div>
<div className="text-sm text-muted-foreground"> {languages.length} .</div>
<div className="flex space-x-2">
{selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}>
@ -759,13 +759,13 @@ export default function MultiLangPage() {
</div>
<div className="flex items-end">
<div className="text-sm text-gray-600"> : {getFilteredLangKeys().length}</div>
<div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
</div>
</div>
{/* 테이블 영역 */}
<div>
<div className="mb-2 text-sm text-gray-600">: {getFilteredLangKeys().length}</div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable
columns={columns}
data={getFilteredLangKeys()}

View File

@ -40,15 +40,15 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
// 디버그: 전달받은 메뉴 데이터 확인
console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
total: menus.length,
sample: menus.slice(0, 3),
keys: menus.length > 0 ? Object.keys(menus[0]) : [],
});
// console.log("ScreenAssignmentTab - 전달받은 메뉴 데이터:", {
// total: menus.length,
// sample: menus.slice(0, 3),
// keys: menus.length > 0 ? Object.keys(menus[0]) : [],
// });
// 메뉴 선택
const handleMenuSelect = async (menuId: string) => {
console.log("메뉴 선택:", menuId);
// console.log("메뉴 선택:", menuId);
setSelectedMenuId(menuId);
// 다양한 형식의 objid 대응
@ -57,7 +57,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
return objid?.toString() === menuId;
});
console.log("선택된 메뉴:", menu);
// console.log("선택된 메뉴:", menu);
setSelectedMenu(menu || null);
if (menu) {
@ -75,7 +75,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
setAssignedScreens(screens);
} catch (error) {
console.error("할당된 화면 로드 실패:", error);
// console.error("할당된 화면 로드 실패:", error);
toast.error("할당된 화면 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
@ -94,7 +94,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
setAvailableScreens(available);
} catch (error) {
console.error("사용 가능한 화면 로드 실패:", error);
// console.error("사용 가능한 화면 로드 실패:", error);
toast.error("사용 가능한 화면 목록을 불러오는데 실패했습니다.");
}
};
@ -118,7 +118,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
setShowAssignDialog(false);
setSelectedScreen(null);
} catch (error) {
console.error("화면 할당 실패:", error);
// console.error("화면 할당 실패:", error);
toast.error("화면 할당에 실패했습니다.");
}
};
@ -142,7 +142,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
setShowUnassignDialog(false);
setSelectedScreen(null);
} catch (error) {
console.error("화면 할당 해제 실패:", error);
// console.error("화면 할당 해제 실패:", error);
toast.error("화면 할당 해제에 실패했습니다.");
}
};
@ -162,14 +162,14 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
// 단순화된 메뉴 옵션 생성 (모든 메뉴를 평면적으로 표시)
const getMenuOptions = (menuList: MenuItem[]): JSX.Element[] => {
console.log("메뉴 옵션 생성:", {
total: menuList.length,
sample: menuList.slice(0, 3).map((m) => ({
objid: m.objid || m.OBJID || (m as any).objid,
name: m.menu_name_kor || m.MENU_NAME_KOR || (m as any).menu_name_kor,
parent: m.parent_obj_id || m.PARENT_OBJ_ID || (m as any).parent_obj_id,
})),
});
// console.log("메뉴 옵션 생성:", {
// total: menuList.length,
// sample: menuList.slice(0, 3).map((m) => ({
// objid: m.objid || m.OBJID || (m as any).objid,
// name: m.menu_name_kor || m.MENU_NAME_KOR || (m as any).menu_name_kor,
// parent: m.parent_obj_id || m.PARENT_OBJ_ID || (m as any).parent_obj_id,
// })),
// });
if (!menuList || menuList.length === 0) {
return [
@ -188,7 +188,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
// 들여쓰기 (레벨에 따라)
const indent = " ".repeat(Math.max(0, lev));
console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
// console.log("메뉴 항목:", { index, menuObjid, menuName, lev });
return (
<SelectItem key={menuObjid?.toString() || `menu-${index}`} value={menuObjid?.toString() || `menu-${index}`}>
@ -231,10 +231,10 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
(selectedMenu as any).menu_name_kor ||
"메뉴"}
</h3>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
URL: {selectedMenu.menu_url || selectedMenu.MENU_URL || (selectedMenu as any).menu_url || "없음"}
</p>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
:{" "}
{selectedMenu.menu_desc || selectedMenu.MENU_DESC || (selectedMenu as any).menu_desc || "없음"}
</p>
@ -294,7 +294,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-muted-foreground">
: {screen.tableName} | : {screen.createdDate.toLocaleDateString()}
</p>
{screen.description && <p className="mt-1 text-sm text-gray-500">{screen.description}</p>}
@ -306,7 +306,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
setSelectedScreen(screen);
setShowUnassignDialog(true);
}}
className="text-red-600 hover:text-red-700"
className="text-destructive hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
@ -347,7 +347,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
<div
key={screen.screenId}
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
selectedScreen?.screenId === screen.screenId ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : "hover:bg-gray-50"
}`}
onClick={() => setSelectedScreen(screen)}
>
@ -357,7 +357,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
{screen.screenCode}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">: {screen.tableName}</p>
<p className="mt-1 text-sm text-muted-foreground">: {screen.tableName}</p>
</div>
))
)}

View File

@ -83,7 +83,7 @@ export function SortableCodeItem({
"cursor-pointer transition-colors",
code.isActive === "Y" || code.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
: "bg-gray-100 text-muted-foreground hover:bg-gray-200 hover:text-gray-700",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
@ -100,7 +100,7 @@ export function SortableCodeItem({
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{code.codeValue || code.code_value}</p>
<p className="mt-1 text-sm text-muted-foreground">{code.codeValue || code.code_value}</p>
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
</div>

View File

@ -117,6 +117,32 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
return;
}
// SELECT 쿼리만 허용하는 검증
const trimmedQuery = query.trim().toUpperCase();
if (!trimmedQuery.startsWith('SELECT')) {
toast({
title: "보안 오류",
description: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다. INSERT, UPDATE, DELETE는 허용되지 않습니다.",
variant: "destructive",
});
return;
}
// 위험한 키워드 검사
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE'];
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
trimmedQuery.includes(keyword)
);
if (hasDangerousKeyword) {
toast({
title: "보안 오류",
description: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
variant: "destructive",
});
return;
}
console.log("쿼리 실행 시작:", { connectionId, query });
setLoading(true);
try {

View File

@ -24,9 +24,9 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
case "success":
return "text-green-600";
case "error":
return "text-red-600";
return "text-destructive";
default:
return "text-blue-600";
return "text-primary";
}
};
@ -37,7 +37,7 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600">{message}</p>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
<div className="flex justify-end">
<Button onClick={onClose} className="w-20">
@ -398,7 +398,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
{/* 중복확인 결과 메시지 */}
{duplicateCheckMessage && (
<div
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-red-600"}`}
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-destructive"}`}
>
{duplicateCheckMessage}
</div>

View File

@ -196,7 +196,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
</div>
{/* 비밀번호 일치 여부 표시 */}
{showMismatchError && <p className="text-sm text-red-600"> .</p>}
{showMismatchError && <p className="text-sm text-destructive"> .</p>}
{isPasswordMatch && <p className="text-sm text-green-600"> .</p>}
</div>

View File

@ -33,8 +33,8 @@ export function UserStatusConfirmDialog({
const currentStatusText = USER_STATUS_LABELS[user.status as keyof typeof USER_STATUS_LABELS] || user.status;
const newStatusText = USER_STATUS_LABELS[newStatus as keyof typeof USER_STATUS_LABELS] || newStatus;
const currentStatusColor = user.status === "active" ? "text-blue-600" : "text-gray-600";
const newStatusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600";
const currentStatusColor = user.status === "active" ? "text-primary" : "text-muted-foreground";
const newStatusColor = newStatus === "active" ? "text-primary" : "text-muted-foreground";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
@ -67,7 +67,7 @@ export function UserStatusConfirmDialog({
<Button variant="outline" onClick={onCancel}>
</Button>
<Button onClick={onConfirm} className={newStatus === "active" ? "bg-blue-500 hover:bg-blue-600" : ""}>
<Button onClick={onConfirm} variant="default">
</Button>
</DialogFooter>

View File

@ -116,14 +116,6 @@ export function UserToolbar({
{/* 고급 검색 필드들 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="사번 검색"
value={searchFilter.search_sabun || ""}
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>

View File

@ -227,7 +227,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
<button
className="
w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-blue-500 hover:text-white
text-gray-400 hover:bg-accent0 hover:text-white
rounded transition-colors duration-200
"
onClick={() => onConfigure(element)}
@ -240,7 +240,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
<button
className="
element-close w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-red-500 hover:text-white
text-gray-400 hover:bg-destructive/100 hover:text-white
rounded transition-colors duration-200
"
onClick={handleRemove}
@ -259,7 +259,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
{isLoadingData ? (
<div className="w-full h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="text-sm"> ...</div>
</div>
</div>

View File

@ -225,7 +225,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
{/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs text-gray-600 space-y-1">
<div className="text-xs text-muted-foreground space-y-1">
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
<div>
<strong>Y축:</strong>{' '}
@ -240,7 +240,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
)}
<div><strong> :</strong> {queryResult.rows.length}</div>
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
<div className="text-blue-600 mt-2">
<div className="text-primary mt-2">
!
</div>
)}
@ -249,7 +249,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
{/* 필수 필드 확인 */}
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="text-red-800 text-sm">
X축과 Y축을 .
</div>

View File

@ -75,7 +75,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
w-full min-h-full relative
bg-gray-100
bg-grid-pattern
${isDragOver ? 'bg-blue-50' : ''}
${isDragOver ? 'bg-accent' : ''}
`}
style={{
backgroundImage: `

View File

@ -207,7 +207,7 @@ export default function DashboardDesigner() {
return (
<div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<div className="text-lg font-medium text-gray-700"> ...</div>
<div className="text-sm text-gray-500 mt-1"> </div>
</div>
@ -221,7 +221,7 @@ export default function DashboardDesigner() {
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
<div className="absolute top-2 left-2 z-10 bg-blue-500 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
📝 : {dashboardTitle}
</div>
)}

View File

@ -31,7 +31,7 @@ export function DashboardSidebar() {
type="chart"
subtype="bar"
onDragStart={handleDragStart}
className="border-l-4 border-blue-500"
className="border-l-4 border-primary"
/>
<DraggableItem
icon="📚"

View File

@ -70,13 +70,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<h2 className="text-xl font-semibold text-gray-800">
{element.title}
</h2>
<p className="text-sm text-gray-600 mt-1">
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl"
className="text-gray-400 hover:text-muted-foreground text-2xl"
>
×
</button>
@ -89,7 +89,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
className={`
px-6 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === 'query'
? 'border-blue-500 text-blue-600 bg-blue-50'
? 'border-primary text-primary bg-accent'
: 'border-transparent text-gray-500 hover:text-gray-700'}
`}
>
@ -100,7 +100,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
className={`
px-6 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === 'chart'
? 'border-blue-500 text-blue-600 bg-blue-50'
? 'border-primary text-primary bg-accent'
: 'border-transparent text-gray-500 hover:text-gray-700'}
`}
>
@ -147,7 +147,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
className="px-4 py-2 text-muted-foreground border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
@ -155,7 +155,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
onClick={handleSave}
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
className="
px-4 py-2 bg-blue-500 text-white rounded-lg
px-4 py-2 bg-accent0 text-white rounded-lg
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
"
>

View File

@ -153,7 +153,7 @@ ORDER BY Q4 DESC;`
onClick={executeQuery}
disabled={isExecuting || !query.trim()}
className="
px-3 py-1 bg-blue-500 text-white rounded text-sm
px-3 py-1 bg-accent0 text-white rounded text-sm
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
flex items-center gap-1
"
@ -172,10 +172,10 @@ ORDER BY Q4 DESC;`
{/* 샘플 쿼리 버튼들 */}
<div className="flex gap-2 flex-wrap">
<span className="text-sm text-gray-600"> :</span>
<span className="text-sm text-muted-foreground"> :</span>
<button
onClick={() => insertSampleQuery('comparison')}
className="px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 rounded font-medium"
className="px-2 py-1 text-xs bg-primary/20 hover:bg-blue-200 rounded font-medium"
>
🔥
</button>
@ -224,7 +224,7 @@ ORDER BY Q4 DESC;`
{/* 새로고침 간격 설정 */}
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600"> :</label>
<label className="text-sm text-muted-foreground"> :</label>
<select
value={dataSource?.refreshInterval || 30000}
onChange={(e) => onDataSourceChange({
@ -246,7 +246,7 @@ ORDER BY Q4 DESC;`
{/* 오류 메시지 */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="text-red-800 text-sm font-medium"> </div>
<div className="text-red-700 text-sm mt-1">{error}</div>
</div>
@ -282,7 +282,7 @@ ORDER BY Q4 DESC;`
{queryResult.rows.slice(0, 10).map((row, idx) => (
<tr key={idx} className="border-b border-gray-100">
{queryResult.columns.map((col, colIdx) => (
<td key={colIdx} className="py-1 px-2 text-gray-600">
<td key={colIdx} className="py-1 px-2 text-muted-foreground">
{String(row[col] ?? '')}
</td>
))}

View File

@ -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" />
);

View File

@ -115,7 +115,7 @@ export function AuthGuard({
console.log("AuthGuard: 로딩 중 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-blue-100 p-4">
<div className="mb-4 rounded bg-primary/20 p-4">
<h3 className="font-bold">AuthGuard ...</h3>
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
</div>
@ -129,10 +129,10 @@ export function AuthGuard({
console.log("AuthGuard: 인증 실패 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-red-100 p-4">
<div className="mb-4 rounded bg-destructive/20 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-red-600">
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
@ -150,7 +150,7 @@ export function AuthGuard({
<div className="mb-4 rounded bg-orange-100 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-red-600">
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}

View File

@ -9,6 +9,6 @@ export function ErrorMessage({ message }: ErrorMessageProps) {
if (!message) return null;
return (
<div className="my-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{message}</div>
<div className="my-4 rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-red-700">{message}</div>
);
}

View File

@ -1,4 +1,4 @@
import { Shield } from "lucide-react";
import Image from "next/image";
import { UI_CONFIG } from "@/constants/auth";
/**
@ -7,10 +7,16 @@ import { UI_CONFIG } from "@/constants/auth";
export function LoginHeader() {
return (
<div className="text-center">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-xl bg-slate-900 shadow-lg">
<Shield className="h-10 w-10 text-white" />
<div className="mx-auto mb-2 flex items-center justify-center">
<Image
src="/images/vexplor.png"
alt={UI_CONFIG.COMPANY_NAME}
width={180}
height={60}
className="object-contain"
priority
/>
</div>
<h1 className="mb-2 text-3xl font-bold text-slate-900">{UI_CONFIG.COMPANY_NAME}</h1>
</div>
);
}

View File

@ -263,7 +263,7 @@ export const createStatusColumn = (accessorKey: string, header: string) => ({
? "bg-gray-50 text-gray-700"
: status === "pending" || status === "대기"
? "bg-yellow-50 text-yellow-700"
: "bg-red-50 text-red-700",
: "bg-destructive/10 text-red-700",
)}
>
{status || "-"}

View File

@ -42,63 +42,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면의 실제 크기 계산 함수
const calculateScreenDimensions = (components: ComponentData[]) => {
let maxWidth = 800; // 최소 너비
let maxHeight = 600; // 최소 높이
// 모든 컴포넌트의 경계 찾기
let minX = Infinity;
let minY = Infinity;
let maxX = 0;
let maxY = 0;
console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length });
components.forEach((component, index) => {
// position과 size는 BaseComponent에서 별도 속성으로 관리
components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0");
const y = parseFloat(component.position?.y?.toString() || "0");
const width = parseFloat(component.size?.width?.toString() || "100");
const height = parseFloat(component.size?.height?.toString() || "40");
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
const rightEdge = x + width;
const bottomEdge = y + height;
console.log(
`📏 컴포넌트 ${index + 1} (${component.id}): x=${x}, y=${y}, w=${width}, h=${height}, rightEdge=${rightEdge}, bottomEdge=${bottomEdge}`,
);
const newMaxWidth = Math.max(maxWidth, rightEdge + 100); // 여백 증가
const newMaxHeight = Math.max(maxHeight, bottomEdge + 100); // 여백 증가
if (newMaxWidth > maxWidth || newMaxHeight > maxHeight) {
console.log(`🔄 크기 업데이트: ${maxWidth}×${maxHeight}${newMaxWidth}×${newMaxHeight}`);
maxWidth = newMaxWidth;
maxHeight = newMaxHeight;
}
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight });
// 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px)
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const padding = 128; // 좌우 또는 상하 합계 여백
// 브라우저 크기 제한 확인 (더욱 관대하게 설정)
const maxAllowedWidth = window.innerWidth * 0.98; // 95% -> 98%
const maxAllowedHeight = window.innerHeight * 0.95; // 90% -> 95%
const finalWidth = Math.max(contentWidth + padding, 400); // 최소 400px
const finalHeight = Math.max(contentHeight + padding, 300); // 최소 300px
console.log("📐 크기 제한 정보:", {
: { maxWidth, maxHeight },
: { maxAllowedWidth, maxAllowedHeight },
: { width: window.innerWidth, height: window.innerHeight },
});
// 컴포넌트 기반 크기를 우선 적용하되, 브라우저 제한을 고려
const finalDimensions = {
width: Math.min(maxWidth, maxAllowedWidth),
height: Math.min(maxHeight, maxAllowedHeight),
return {
width: Math.min(finalWidth, window.innerWidth * 0.98),
height: Math.min(finalHeight, window.innerHeight * 0.95),
};
console.log("✅ 최종 화면 크기:", finalDimensions);
console.log("🔧 크기 적용 분석:", {
width적용: maxWidth <= maxAllowedWidth ? "컴포넌트기준" : "브라우저제한",
height적용: maxHeight <= maxAllowedHeight ? "컴포넌트기준" : "브라우저제한",
: { maxWidth, maxHeight },
최종크기: finalDimensions,
});
return finalDimensions;
};
// 전역 모달 이벤트 리스너
@ -113,10 +86,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
});
};
const handleCloseModal = () => {
console.log("🚪 ScreenModal 닫기 이벤트 수신");
setModalState({
isOpen: false,
screenId: null,
title: "",
size: "md",
});
setScreenData(null);
setFormData({});
};
window.addEventListener("openScreenModal", handleOpenModal as EventListener);
window.addEventListener("closeSaveModal", handleCloseModal);
return () => {
window.removeEventListener("openScreenModal", handleOpenModal as EventListener);
window.removeEventListener("closeSaveModal", handleCloseModal);
};
}, []);
@ -190,17 +177,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
}
// 헤더 높이와 패딩을 고려한 전체 높이 계산 (실제 측정값 기반)
const headerHeight = 80; // DialogHeader + 패딩 (더 정확한 값)
// 헤더 높이만 고려 (패딩 제거)
const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함)
const totalHeight = screenDimensions.height + headerHeight;
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용
maxWidth: "98vw", // 안전장치
maxHeight: "95vh", // 안전장치
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, // 화면 크기 그대로
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 헤더 + 화면 높이
maxWidth: "98vw",
maxHeight: "95vh",
},
};
};
@ -215,12 +202,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
</DialogHeader>
<div className="flex-1 p-4">
<div className="flex-1 flex items-center justify-center overflow-hidden">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-600"> ...</p>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
) : screenData ? (
@ -229,6 +216,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
transformOrigin: 'center center',
maxWidth: '100%',
maxHeight: '100%',
}}
>
{screenData.components.map((component) => (
@ -258,7 +248,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-gray-600"> .</p>
<p className="text-muted-foreground"> .</p>
</div>
)}
</div>

View File

@ -18,6 +18,6 @@ export function ValidationMessage({ message, isValid, isLoading, className }: Va
}
return (
<p className={cn("text-sm transition-colors", isValid ? "text-green-600" : "text-red-600", className)}>{message}</p>
<p className={cn("text-sm transition-colors", isValid ? "text-green-600" : "text-destructive", className)}>{message}</p>
);
}

View File

@ -105,10 +105,10 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
return (
<div className="relative w-full h-full bg-gray-100 overflow-auto">
{/* 새로고침 상태 표시 */}
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600">
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-muted-foreground">
: {lastRefresh.toLocaleTimeString()}
{Array.from(loadingElements).length > 0 && (
<span className="ml-2 text-blue-600">
<span className="ml-2 text-primary">
({Array.from(loadingElements).length} ...)
</span>
)}
@ -164,7 +164,7 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
className="text-gray-400 hover:text-muted-foreground disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
@ -203,8 +203,8 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{isLoading && (
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
<div className="text-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="text-sm text-gray-600"> ...</div>
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="text-sm text-muted-foreground"> ...</div>
</div>
</div>
)}

View File

@ -244,7 +244,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
</div>
</TableCell>
<TableCell>
<div className="flex items-center text-sm text-gray-600">
<div className="text-muted-foreground flex items-center text-sm">
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
{new Date(diagram.updatedAt).toLocaleDateString()}
</div>
@ -269,7 +269,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-red-600">
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
@ -302,7 +302,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
>
</Button>
<span className="text-sm text-gray-600">
<span className="text-muted-foreground text-sm">
{currentPage} / {totalPages}
</span>
<Button

View File

@ -54,7 +54,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
<button
onClick={onClearAll}
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
className="w-full rounded-lg bg-destructive/100 p-3 font-medium text-white transition-colors hover:bg-red-600"
>
🗑
</button>
@ -72,7 +72,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
{/* 통계 정보 */}
<div className="mt-6 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700"></div>
<div className="space-y-1 text-sm text-gray-600">
<div className="space-y-1 text-sm text-muted-foreground">
<div className="flex justify-between">
<span> :</span>
<span className="font-medium">{nodes.length}</span>

View File

@ -85,11 +85,11 @@ export const EdgeInfoPanel: React.FC<EdgeInfoPanelProps> = ({
{/* 관계 화살표 */}
<div className="flex justify-center">
<span className="text-l text-gray-600"></span>
<span className="text-l text-muted-foreground"></span>
</div>
{/* To 테이블 */}
<div className="rounded-lg border-l-4 border-blue-400 bg-blue-50 p-3">
<div className="rounded-lg border-l-4 border-blue-400 bg-accent p-3">
<div className="mb-2 text-xs font-bold tracking-wide text-blue-700 uppercase">TO</div>
<div className="mb-2 text-base font-bold text-gray-800">{edgeInfo.toTable}</div>
<div className="space-y-1">
@ -97,7 +97,7 @@ export const EdgeInfoPanel: React.FC<EdgeInfoPanelProps> = ({
{edgeInfo.toColumns.map((column, index) => (
<span
key={index}
className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
className="inline-flex items-center rounded-md bg-primary/20 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
>
{column}
</span>

View File

@ -134,18 +134,18 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
};
return (
<div className="pointer-events-auto absolute top-4 right-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
<div className="pointer-events-auto absolute top-4 right-4 z-40 w-80 rounded-xl border border-primary/20 bg-white shadow-lg">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
<div className="flex items-center gap-2">
<div className="rounded-full bg-blue-100 p-1">
<span className="text-sm text-blue-600">🔗</span>
<div className="rounded-full bg-primary/20 p-1">
<span className="text-sm text-primary">🔗</span>
</div>
<div className="text-sm font-semibold text-gray-800"> </div>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-muted-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -159,7 +159,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
{relationships.map((relationship) => (
<div
key={relationship.id}
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-accent"
>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">
@ -172,7 +172,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
e.stopPropagation();
handleEdit(relationship);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-blue-100 hover:text-blue-600"
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-primary/20 hover:text-primary"
title="관계 편집"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -190,7 +190,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
e.stopPropagation();
handleDelete(relationship);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-600"
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-destructive/20 hover:text-destructive"
title="관계 삭제"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -204,7 +204,7 @@ export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-600">
<div className="space-y-1 text-xs text-muted-foreground">
<p>: {relationship.connectionType}</p>
<p>From: {relationship.fromTable}</p>
<p>To: {relationship.toTable}</p>

View File

@ -149,26 +149,26 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
onKeyPress={handleKeyPress}
placeholder="예: 사용자-부서 관계도"
disabled={isLoading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
className={nameError ? "border-destructive focus:border-destructive" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
{nameError && <p className="text-sm text-destructive">{nameError}</p>}
</div>
{/* 관계 요약 정보 */}
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-2xl font-bold text-primary">{relationships.length}</div>
<div className="text-sm text-muted-foreground"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
</div>
<div className="text-sm text-gray-600"> </div>
<div className="text-sm text-muted-foreground"> </div>
</div>
</div>
@ -212,7 +212,7 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
{relationship.relationshipName || `${relationship.fromTable}${relationship.toTable}`}
</span>
</div>
<div className="mt-1 text-xs text-gray-600">
<div className="mt-1 text-xs text-muted-foreground">
{relationship.fromTable} {relationship.toTable}
</div>
</div>

View File

@ -24,12 +24,12 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
canCreateConnection,
}) => {
return (
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-primary/20 bg-white shadow-lg">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm text-blue-600">📋</span>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/20">
<span className="text-sm text-primary">📋</span>
</div>
<div>
<div className="text-sm font-semibold text-gray-800"> </div>
@ -44,7 +44,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
</div>
<button
onClick={onClose}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-muted-foreground"
>
</button>
@ -66,8 +66,8 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
index === 0
? "border-l-4 border-emerald-400 bg-emerald-50"
: index === 1
? "border-l-4 border-blue-400 bg-blue-50"
: "bg-gray-50"
? "border-l-4 border-blue-400 bg-accent"
: "bg-muted"
}`}
>
<div className="mb-1 flex items-center justify-between">
@ -88,7 +88,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
</div>
)}
</div>
<div className="text-xs text-gray-600">{tableName}</div>
<div className="text-xs text-muted-foreground">{tableName}</div>
</div>
{/* 연결 화살표 (마지막이 아닌 경우) */}
@ -110,7 +110,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
disabled={!canCreateConnection}
className={`flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
canCreateConnection
? "bg-blue-500 text-white hover:bg-blue-600"
? "bg-accent0 text-white hover:bg-blue-600"
: "cursor-not-allowed bg-gray-300 text-gray-500"
}`}
>
@ -119,7 +119,7 @@ export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
</button>
<button
onClick={onClear}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-600 hover:bg-gray-300"
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-gray-300"
>
<span>🗑</span>
<span></span>

View File

@ -56,7 +56,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<div
key={columnKey}
className={`relative cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
isSelected ? "bg-blue-100 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
isSelected ? "bg-primary/20 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => onColumnClick(table.tableName, columnKey)}
>

View File

@ -93,7 +93,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
</div>
{/* 오류 메시지 */}
{error && <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
{error && <div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">{error}</div>}
{/* 테이블 목록 */}
<div className="max-h-96 space-y-2 overflow-y-auto">
@ -114,7 +114,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
<Card
key={table.tableName}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? "cursor-not-allowed border-blue-500 bg-blue-50 opacity-60" : "hover:border-gray-300"
isSelected ? "cursor-not-allowed border-primary bg-accent opacity-60" : "hover:border-gray-300"
}`}
onDoubleClick={() => !isSelected && handleAddTable(table)}
>
@ -126,10 +126,10 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-600">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span className="font-mono">{table.tableName}</span>
{isSelected && <span className="font-medium text-blue-600">()</span>}
{isSelected && <span className="font-medium text-primary">()</span>}
</div>
{table.description && <p className="line-clamp-2 text-xs text-gray-500">{table.description}</p>}
@ -142,7 +142,7 @@ export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTab
</div>
{/* 통계 정보 */}
<div className="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
<div className="rounded-lg bg-gray-50 p-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span> : {tables.length}</span>
{searchTerm && <span> : {filteredTables.length}</span>}

View File

@ -81,7 +81,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
value={condition.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
>
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -92,11 +92,11 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
)}
{/* 그룹 레벨에 따른 들여쓰기 */}
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-accent/50 p-2"
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
>
<span className="font-mono text-sm text-blue-600">(</span>
<span className="text-xs text-blue-600"> </span>
<span className="font-mono text-sm text-primary">(</span>
<span className="text-xs text-primary"> </span>
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
<Trash2 className="h-3 w-3" />
</Button>
@ -110,11 +110,11 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
return (
<div key={condition.id} className="flex items-center gap-2">
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-accent/50 p-2"
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
>
<span className="font-mono text-sm text-blue-600">)</span>
<span className="text-xs text-blue-600"> </span>
<span className="font-mono text-sm text-primary">)</span>
<span className="text-xs text-primary"> </span>
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
<Trash2 className="h-3 w-3" />
</Button>
@ -126,7 +126,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
value={conditions[index + 1]?.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index + 1, "logicalOperator", value)}
>
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -150,7 +150,7 @@ export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
value={condition.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
>
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<SelectTrigger className="h-8 w-24 border-primary/20 bg-accent text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@ -400,7 +400,7 @@ export const WebTypeInput: React.FC<WebTypeInputProps> = ({
multiple={detailSettings.multiple as boolean}
/>
{value && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Upload className="h-4 w-4" />
<span> : {value}</span>
</div>

View File

@ -84,24 +84,24 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
<summary
className={`flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium hover:bg-gray-50 hover:text-gray-900 ${
isConditionRequired && !hasValidConditions
? "border-red-300 bg-red-50 text-red-700"
? "border-red-300 bg-destructive/10 text-red-700"
: "border-gray-200 text-gray-700"
}`}
>
<div className="flex items-center gap-2">
🔍
{isConditionRequired ? (
<span className="rounded bg-red-100 px-1 py-0.5 text-xs font-semibold text-red-700"></span>
<span className="rounded bg-destructive/20 px-1 py-0.5 text-xs font-semibold text-red-700"></span>
) : (
<span className="text-gray-500">()</span>
)}
{action.conditions && action.conditions.length > 0 && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs text-blue-700">
{action.conditions.length}
</span>
)}
{isConditionRequired && !hasValidConditions && (
<span className="rounded bg-red-100 px-1 py-0.5 text-xs text-red-600"> </span>
<span className="rounded bg-destructive/20 px-1 py-0.5 text-xs text-destructive"> </span>
)}
</div>
{action.conditions && action.conditions.length > 0 && (
@ -151,8 +151,8 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
<div
className={`rounded border p-3 text-xs ${
isConditionRequired
? "border-red-200 bg-red-50 text-red-700"
: "border-gray-200 bg-gray-50 text-gray-600"
? "border-destructive/20 bg-destructive/10 text-red-700"
: "border-gray-200 bg-gray-50 text-muted-foreground"
}`}
>
{isConditionRequired ? (

View File

@ -228,7 +228,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium"> </Label>
<span className="text-xs text-red-600">()</span>
<span className="text-xs text-destructive">()</span>
</div>
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
<Plus className="mr-1 h-2 w-2" />
@ -244,7 +244,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
{/* 컴팩트한 매핑 표시 */}
<div className="flex items-center gap-2 text-xs">
{/* 소스 */}
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
<div className="flex items-center gap-1 rounded bg-accent px-2 py-1">
<Select
value={mapping.sourceTable || "__EMPTY__"}
onValueChange={(value) => {
@ -277,7 +277,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
updateFieldMapping(mappingIndex, "sourceTable", "");
updateFieldMapping(mappingIndex, "sourceField", "");
}}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-muted-foreground"
title="소스 테이블 지우기"
>
×
@ -390,7 +390,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
{/* 필드 매핑이 없을 때 안내 메시지 */}
{action.fieldMappings.length === 0 && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-xs text-red-700">
<div className="rounded border border-destructive/20 bg-destructive/10 p-3 text-xs text-red-700">
<div className="flex items-start gap-2">
<span className="text-red-500"></span>
<div>

View File

@ -190,7 +190,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
: isMapped
? "bg-gray-100 text-gray-700"
: oppositeSelectedColumn && !isTypeCompatible
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
? "cursor-not-allowed bg-destructive/10 text-red-400 opacity-60"
: isClickable
? "cursor-pointer hover:bg-gray-50"
: "cursor-not-allowed bg-gray-100 text-gray-400"
@ -250,7 +250,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
: hasDefaultValue
? "bg-gray-100"
: oppositeSelectedColumn && !isTypeCompatible
? "bg-red-50 opacity-60"
? "bg-destructive/10 opacity-60"
: "bg-white"
}`}
>
@ -292,7 +292,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
{isMapped && (
<div className="mt-2 flex items-center gap-2">
<span className="truncate text-xs text-blue-600"> {mapping.fromColumnName}</span>
<span className="truncate text-xs text-primary"> {mapping.fromColumnName}</span>
<button
onClick={(e) => {
e.stopPropagation();
@ -327,7 +327,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
</div>
{/* 하단 통계 */}
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}

View File

@ -18,14 +18,14 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "simple-key"
? "border-blue-500 bg-blue-50"
? "border-primary bg-accent"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => onConfigChange({ ...config, connectionType: "simple-key" })}
>
<Key className="mx-auto h-6 w-6 text-blue-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<div
@ -38,7 +38,7 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
>
<Save className="mx-auto h-6 w-6 text-green-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<div
@ -54,7 +54,7 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
>
<Globe className="mx-auto h-6 w-6 text-orange-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600">API/ </div>
<div className="text-xs text-muted-foreground">API/ </div>
</div>
</div>
</div>

View File

@ -299,7 +299,7 @@ export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">
<span className="text-red-600">!=</span>
<span className="text-destructive">!=</span>
</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
@ -308,11 +308,11 @@ export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">
<span className="text-red-600">NOT IN</span>
<span className="text-destructive">NOT IN</span>
</SelectItem>
<SelectItem value="EXISTS">EXISTS</SelectItem>
<SelectItem value="NOT EXISTS">
<span className="text-red-600">NOT EXISTS</span>
<span className="text-destructive">NOT EXISTS</span>
</SelectItem>
</SelectContent>
</Select>

View File

@ -446,9 +446,9 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
<div className="flex items-center gap-3">
<div>
<div className="font-semibold text-gray-800"> </div>
<div className="text-sm text-gray-600">
<div className="text-sm text-muted-foreground">
{toTableColumns.length} {" "}
<span className="font-bold text-blue-600">
<span className="font-bold text-primary">
{columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length}
</span>{" "}

View File

@ -44,7 +44,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
{/* 현재 선택된 테이블 표시 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<Label className="text-xs font-medium text-muted-foreground">From </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
@ -54,7 +54,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<Label className="text-xs font-medium text-muted-foreground">To </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
@ -67,7 +67,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
{/* 컬럼 선택 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<Label className="text-xs font-medium text-muted-foreground">From </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{fromTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
@ -100,7 +100,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<Label className="text-xs font-medium text-muted-foreground">To </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{toTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
@ -137,7 +137,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600"> From </Label>
<Label className="text-xs font-medium text-muted-foreground"> From </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedFromColumns.length > 0 ? (
selectedFromColumns.map((column) => {
@ -156,7 +156,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
</div>
<div>
<Label className="text-xs font-medium text-gray-600"> To </Label>
<Label className="text-xs font-medium text-muted-foreground"> To </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedToColumns.length > 0 ? (
selectedToColumns.map((column) => {
@ -178,7 +178,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
</div>
{/* 단순 키값 연결 설정 */}
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-accent/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Key className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium"> </span>

View File

@ -1,46 +1,17 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { X, ArrowLeft } from "lucide-react";
import React, { useState } from "react";
import { ConnectionTypeSelector } from "./LeftPanel/ConnectionTypeSelector";
import { MappingInfoPanel } from "./LeftPanel/MappingInfoPanel";
import { StepProgress } from "./RightPanel/StepProgress";
import { ConnectionStep } from "./RightPanel/ConnectionStep";
import { TableStep } from "./RightPanel/TableStep";
import { FieldMappingStep } from "./RightPanel/FieldMappingStep";
import { DataConnectionState } from "./types/redesigned";
import { ResponsiveContainer, ResponsiveGrid } from "@/components/layout/ResponsiveContainer";
import { useResponsive } from "@/lib/hooks/useResponsive";
// API import
import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
// 타입 import
import {
DataConnectionState,
DataConnectionActions,
DataConnectionDesignerProps,
FieldMapping,
ValidationResult,
TestResult,
MappingStats,
ActionGroup,
SingleAction,
} from "./types/redesigned";
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
// 컴포넌트 import
import LeftPanel from "./LeftPanel/LeftPanel";
import RightPanel from "./RightPanel/RightPanel";
/**
* 🎨
* - (30% + 70%)
* -
* -
*/
const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
onClose,
initialData,
showBackButton = false,
}) => {
// 🔄 상태 관리
const [state, setState] = useState<DataConnectionState>(() => ({
const initialState: DataConnectionState = {
connectionType: "data_save",
currentStep: 1,
fieldMappings: [],
@ -52,34 +23,6 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
estimatedRows: 0,
actionType: "INSERT",
},
// 제어 실행 조건 초기값
controlConditions: [],
// 액션 그룹 초기값 (멀티 액션)
actionGroups: [
{
id: "group_1",
name: "기본 액션 그룹",
logicalOperator: "AND" as const,
actions: [
{
id: "action_1",
name: "액션 1",
actionType: "insert" as const,
conditions: [],
fieldMappings: [],
isEnabled: true,
},
],
isEnabled: true,
},
],
groupsLogicalOperator: "AND" as "AND" | "OR",
// 기존 호환성 필드들 (deprecated)
actionType: "insert",
actionConditions: [],
actionFieldMappings: [],
isLoading: false,
validationErrors: [],
@ -777,23 +720,30 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
};
return (
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
{/* 상단 네비게이션 */}
{showBackButton && (
<div className="flex-shrink-0 border-b bg-white shadow-sm">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={onClose} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">🔗 </h1>
<p className="text-muted-foreground text-sm">
{state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"}
<div className="h-screen bg-gradient-to-br from-slate-50 to-gray-100">
<div className="bg-white border-b border-gray-200 px-6 py-4">
<h1 className="text-2xl font-bold text-gray-900">
🎨 -
</h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
</div>
<div className="flex h-[calc(100vh-80px)]">
<div className="w-[30%] bg-white border-r border-gray-200 flex flex-col">
<ConnectionTypeSelector
connectionType={state.connectionType}
onConnectionTypeChange={(type) => setState(prev => ({ ...prev, connectionType: type }))}
/>
<div className="flex-1 overflow-y-auto">
<MappingInfoPanel
mappingStats={state.mappingStats}
fieldMappings={state.fieldMappings}
selectedMapping={state.selectedMapping}
onMappingSelect={(mappingId) => setState(prev => ({ ...prev, selectedMapping: mappingId }))}
/>
</div>
</div>
)}

View File

@ -51,7 +51,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
<>
{/* 트랜잭션 설정 - 컴팩트 */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-600">🔄 </h4>
<h4 className="text-xs font-medium text-muted-foreground">🔄 </h4>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="batchSize" className="text-xs text-gray-500">
@ -98,7 +98,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
<>
{/* API 호출 설정 - 컴팩트 */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-600">🌐 API </h4>
<h4 className="text-xs font-medium text-muted-foreground">🌐 API </h4>
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="timeout" className="text-xs text-gray-500">
@ -131,7 +131,7 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) =
{/* 로깅 설정 - 컴팩트 */}
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-600">📝 </h4>
<h4 className="text-xs font-medium text-muted-foreground">📝 </h4>
<div>
<Select value={settings.logLevel} onValueChange={(value) => handleSettingChange("logLevel", value)}>
<SelectTrigger className="h-7 text-xs">

View File

@ -1,62 +1,66 @@
"use client";
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Database, Globe } from "lucide-react";
import { ConnectionType } from "../types/redesigned";
// 타입 import
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
interface ConnectionTypeSelectorProps {
connectionType: "data_save" | "external_call";
onConnectionTypeChange: (type: "data_save" | "external_call") => void;
}
/**
* 🔘
* - (INSERT/UPDATE/DELETE)
* - (API/Webhook)
*/
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
const connectionTypes: ConnectionType[] = [
{
id: "data_save",
label: "데이터 저장",
description: "INSERT/UPDATE/DELETE 작업",
icon: <Database className="h-4 w-4" />,
},
{
id: "external_call",
label: "외부 호출",
description: "API/Webhook 호출",
icon: <Globe className="h-4 w-4" />,
},
];
const connectionTypes: ConnectionType[] = [
{
id: "data_save",
label: "데이터 저장",
description: "INSERT/UPDATE/DELETE 작업",
icon: <Database className="w-6 h-6" />,
},
{
id: "external_call",
label: "외부 호출",
description: "API/Webhook 호출",
icon: <Globe className="w-6 h-6" />,
},
];
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
connectionType,
onConnectionTypeChange,
}) => {
return (
<Card>
<CardContent className="p-4">
<RadioGroup
value={selectedType}
onValueChange={(value) => {
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
onTypeChange(value as "data_save" | "external_call");
}}
className="space-y-3"
>
{connectionTypes.map((type) => (
<div key={type.id} className="flex items-start space-x-3">
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
<div className="min-w-0 flex-1">
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
{type.icon}
{type.label}
</Label>
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
</h2>
<div className="space-y-3">
{connectionTypes.map((type) => (
<div
key={type.id}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
connectionType === type.id
? "border-orange-500 bg-orange-50 shadow-md"
: "border-gray-200 bg-white hover:border-orange-300 hover:bg-orange-25"
}`}
onClick={() => onConnectionTypeChange(type.id)}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
connectionType === type.id
? "bg-orange-100 text-orange-600"
: "bg-gray-100 text-muted-foreground"
}`}>
{type.icon}
</div>
<div>
<h3 className="font-medium text-gray-900">{type.label}</h3>
<p className="text-sm text-muted-foreground">{type.description}</p>
</div>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
</div>
))}
</div>
</div>
);
};
export default ConnectionTypeSelector;
};

View File

@ -88,9 +88,9 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
{state.connectionType === "external_call" && (
<>
<Separator />
<div className="rounded-md bg-blue-50 p-3">
<div className="rounded-md bg-accent p-3">
<h3 className="mb-1 text-sm font-medium text-blue-800"> </h3>
<p className="text-xs text-blue-600"> REST API .</p>
<p className="text-xs text-primary"> REST API .</p>
</div>
</>
)}

Some files were not shown because too many files have changed in this diff Show More