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

This commit is contained in:
kjs 2025-10-13 18:28:05 +09:00
commit 216e19e25d
101 changed files with 19680 additions and 4658 deletions

View File

@ -601,4 +601,200 @@ export default function EmptyStatePage() {
--- ---
## 📧 메일 관리 시스템 UI 개선사항
### 최근 업데이트 (2025-01-02)
#### 1. 메일 발송 페이지 헤더 개선
**변경 전:**
```tsx
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-lg">
<Send className="w-6 h-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">메일 발송</h1>
<p className="text-sm text-gray-500">설명</p>
</div>
</div>
</div>
```
**변경 후 (표준 헤더 카드 적용):**
```tsx
<Card>
<CardHeader>
<CardTitle className="text-2xl">메일 발송</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
</p>
</CardHeader>
</Card>
```
**개선 사항:**
- ✅ 불필요한 아이콘 제거 (종이비행기)
- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
- ✅ 다른 페이지와 동일한 헤더 스타일 적용
#### 2. 메일 내용 입력 개선
**변경 전:**
```tsx
<Textarea placeholder="메일 내용을 html로 작성하세요" />
```
**변경 후:**
```tsx
<Textarea
placeholder="메일 내용을 입력하세요
줄바꿈은 자동으로 처리됩니다."
/>
<p className="text-xs text-gray-500 mt-1">
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
</p>
```
**개선 사항:**
- ✅ HTML 지식 없이도 사용 가능
- ✅ 일반 텍스트 입력 후 자동 HTML 변환
- ✅ 사용자 친화적인 안내 메시지
#### 3. CC/BCC 기능 추가
**구현 내용:**
```tsx
{/* To 태그 입력 */}
<EmailTagInput
tags={to}
onTagsChange={setTo}
placeholder="받는 사람 이메일"
/>
{/* CC 태그 입력 */}
<EmailTagInput
tags={cc}
onTagsChange={setCc}
placeholder="참조 (선택사항)"
/>
{/* BCC 태그 입력 */}
<EmailTagInput
tags={bcc}
onTagsChange={setBcc}
placeholder="숨은참조 (선택사항)"
/>
```
**특징:**
- ✅ 이메일 주소를 태그 형태로 시각화
- ✅ 쉼표로 구분하여 입력 가능
- ✅ 개별 삭제 가능
#### 4. 파일 첨부 기능 (Phase 1 완료)
**백엔드 구현:**
```typescript
// multer 설정
export const uploadMailAttachment = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한
files: 5, // 최대 5개 파일
},
});
// 발송 API
router.post(
'/simple',
uploadMailAttachment.array('attachments', 5),
(req, res) => mailSendSimpleController.sendMail(req, res)
);
```
**보안 기능:**
- ✅ 위험한 파일 확장자 차단 (.exe, .bat, .cmd, .sh 등)
- ✅ 파일 크기 제한 (10MB)
- ✅ 파일 개수 제한 (최대 5개)
- ✅ 안전한 파일명 생성
**프론트엔드 구현 예정 (Phase 1-3):**
- 드래그 앤 드롭 파일 업로드
- 첨부된 파일 목록 표시
- 파일 삭제 기능
- 미리보기에 첨부파일 정보 표시
#### 5. 향후 작업 계획
**Phase 2: 보낸메일함 백엔드**
- 발송 이력 자동 저장 (JSON 파일)
- 발송 상태 관리 (성공/실패)
- 발송 이력 조회 API
**Phase 3: 보낸메일함 프론트엔드**
- `/admin/mail/sent` 페이지
- 발송 목록 테이블
- 상세보기 모달
- 재전송 기능
**Phase 4: 대시보드 통합**
- 대시보드에 "보낸메일함" 링크
- 실제 발송 통계 연동
- 최근 활동 목록
### 메일 시스템 UI 가이드
#### 이메일 태그 입력
```tsx
// 이메일 주소를 시각적으로 표시
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<div
key={index}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md text-sm"
>
<Mail className="w-3 h-3" />
{tag}
<button onClick={() => removeTag(index)}>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
```
#### 파일 첨부 영역 (예정)
```tsx
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-orange-400 transition-colors">
<input type="file" multiple className="hidden" />
<div className="text-center">
<Upload className="w-12 h-12 mx-auto text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
파일을 드래그하거나 클릭하여 선택하세요
</p>
<p className="text-xs text-gray-500 mt-1">
최대 5개, 각 10MB 이하
</p>
</div>
</div>
```
#### 발송 성공 토스트
```tsx
<div className="fixed top-4 right-4 bg-white rounded-lg shadow-lg border border-green-200 p-4">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-gray-900">메일이 발송되었습니다</p>
<p className="text-sm text-gray-600">
{to.length}명에게 전송 완료
</p>
</div>
</div>
</div>
```
---
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨ **이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨

View File

@ -0,0 +1,29 @@
{
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
"sentAt": "2025-10-13T01:08:34.764Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
"sentAt": "2025-10-02T07:50:25.817Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅣ;ㅏㅓ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
{
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
"sentAt": "2025-10-02T07:44:38.128Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "asd",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "웨이스-임직원-프로파일-이희진.pptx",
"originalName": "웨이스-임직원-프로파일-이희진.pptx",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
"mimetype": "image/jpeg"
}
],
"status": "success",
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
{
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
"sentAt": "2025-10-13T00:53:55.193Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "한글.txt",
"originalName": "한글.txt",
"size": 0,
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
"mimetype": "text/plain"
}
],
"status": "success",
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
"sentAt": "2025-10-02T08:22:14.721Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
"sentAt": "2025-10-02T08:41:42.086Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,48 @@
{
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
"sentAt": "2025-10-02T08:57:48.412Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
"sentAt": "2025-10-02T08:49:30.356Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글2",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,41 @@
{
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
"sentAt": "2025-10-02T08:47:03.481Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트222",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
{
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
"sentAt": "2025-10-02T08:48:29.740Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글한글",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,29 @@
{
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
"sentAt": "2025-10-13T00:21:51.799Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "test용입니다.",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"templateId": "template-1759302346758",
"templateName": "test",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
"mimetype": "application/x-iwork-keynote-sffkey"
}
],
"status": "success",
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

15
backend-node/nodemon.json Normal file
View File

@ -0,0 +1,15 @@
{
"watch": ["src"],
"ignore": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"data/**",
"uploads/**",
"logs/**",
"*.log"
],
"ext": "ts,json",
"exec": "ts-node src/app.ts",
"delay": 2000
}

View File

@ -18,6 +18,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@ -30,6 +31,7 @@
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
@ -994,6 +996,15 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -4237,6 +4248,18 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/body-parser/node_modules/ms": { "node_modules/body-parser/node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -6365,15 +6388,19 @@
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
@ -8030,22 +8057,6 @@
"node": ">= 8.0" "node": ">= 8.0"
} }
}, },
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": { "node_modules/named-placeholders": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
@ -8940,6 +8951,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -10161,12 +10184,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {

View File

@ -32,6 +32,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@ -44,6 +45,7 @@
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -32,6 +32,7 @@ import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes"; import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes"; import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes"; import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
import dataRoutes from "./routes/dataRoutes"; import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
@ -48,6 +49,7 @@ import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes"; import dashboardRoutes from "./routes/dashboardRoutes";
import reportRoutes from "./routes/reportRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -72,24 +74,33 @@ app.use(
}) })
); );
app.use(compression()); app.use(compression());
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" }));
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
app.options("/uploads/*", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.sendStatus(200);
});
// 정적 파일 서빙 (업로드된 파일들) // 정적 파일 서빙 (업로드된 파일들)
app.use( app.use(
"/uploads", "/uploads",
express.static(path.join(process.cwd(), "uploads"), { (req, res, next) => {
setHeaders: (res, path) => { // 모든 정적 파일 요청에 CORS 헤더 추가
// 파일 서빙 시 CORS 헤더 설정 res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader(
res.setHeader( "Access-Control-Allow-Headers",
"Access-Control-Allow-Headers", "Content-Type, Authorization"
"Content-Type, Authorization" );
); res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.setHeader("Cache-Control", "public, max-age=3600"); res.setHeader("Cache-Control", "public, max-age=3600");
}, next();
}) },
express.static(path.join(process.cwd(), "uploads"))
); );
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
@ -164,7 +175,19 @@ app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정 app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿 app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송 app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
// 메일 수신 라우트 디버깅 - 모든 요청 로깅
app.use("/api/mail/receive", (req, res, next) => {
console.log(`\n🔍 [MAIL RECEIVE REQUEST]`);
console.log(` Method: ${req.method}`);
console.log(` URL: ${req.originalUrl}`);
console.log(` Path: ${req.path}`);
console.log(` Base URL: ${req.baseUrl}`);
console.log(` Params: ${JSON.stringify(req.params)}`);
console.log(` Query: ${JSON.stringify(req.query)}`);
next();
});
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신 app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
app.use("/api/screen", screenStandardRoutes); app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes); app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
@ -181,6 +204,7 @@ app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes); app.use("/api/dashboards", dashboardRoutes);
app.use("/api/admin/reports", reportRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -0,0 +1,118 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
? '/app/uploads/mail-attachments'
: path.join(process.cwd(), 'uploads', 'mail-attachments');
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
try {
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
} catch (error) {
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
}
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
function normalizeFileName(filename: string): string {
if (!filename) return filename;
try {
// NFC 정규화만 수행 (복잡한 디코딩 제거)
return filename.normalize('NFC');
} catch (error) {
console.error(`Failed to normalize filename: ${filename}`, error);
return filename;
}
}
// 파일 저장 설정
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
try {
// 파일명 정규화 (한글-분석.txt 방식)
file.originalname = file.originalname.normalize('NFC');
console.log('File upload - Processing:', {
original: file.originalname,
originalHex: Buffer.from(file.originalname).toString('hex'),
});
// UUID + 확장자로 유니크한 파일명 생성
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
const filename = `${uniqueId}${ext}`;
console.log('Generated filename:', {
original: file.originalname,
generated: filename,
});
cb(null, filename);
} catch (error) {
console.error('Filename processing error:', error);
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
cb(null, fallbackFilename);
}
},
});
// 파일 필터 (허용할 파일 타입)
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
try {
// NFD를 NFC로 정규화만 수행
file.originalname = file.originalname.normalize('NFC');
} catch (error) {
console.warn('Failed to normalize filename in fileFilter:', error);
}
// 위험한 파일 확장자 차단
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
const ext = path.extname(file.originalname).toLowerCase();
if (dangerousExtensions.includes(ext)) {
console.log(`❌ 차단된 파일 타입: ${ext}`);
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
return;
}
cb(null, true);
};
// Multer 설정
export const uploadMailAttachment = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한
files: 5, // 최대 5개 파일
},
});
// 첨부파일 정보 추출 헬퍼
export interface AttachmentInfo {
filename: string;
originalName: string;
size: number;
path: string;
mimetype: string;
}
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
return files.map((file) => ({
filename: file.filename,
originalName: file.originalname,
size: file.size,
path: file.path,
mimetype: file.mimetype,
}));
};

View File

@ -18,6 +18,12 @@ export class MailReceiveBasicController {
*/ */
async getMailList(req: Request, res: Response) { async getMailList(req: Request, res: Response) {
try { try {
console.log('📬 메일 목록 조회 요청:', {
params: req.params,
path: req.path,
originalUrl: req.originalUrl
});
const { accountId } = req.params; const { accountId } = req.params;
const limit = parseInt(req.query.limit as string) || 50; const limit = parseInt(req.query.limit as string) || 50;
@ -43,6 +49,12 @@ export class MailReceiveBasicController {
*/ */
async getMailDetail(req: Request, res: Response) { async getMailDetail(req: Request, res: Response) {
try { try {
console.log('🔍 메일 상세 조회 요청:', {
params: req.params,
path: req.path,
originalUrl: req.originalUrl
});
const { accountId, seqno } = req.params; const { accountId, seqno } = req.params;
const seqnoNumber = parseInt(seqno, 10); const seqnoNumber = parseInt(seqno, 10);
@ -109,29 +121,39 @@ export class MailReceiveBasicController {
*/ */
async downloadAttachment(req: Request, res: Response) { async downloadAttachment(req: Request, res: Response) {
try { try {
console.log('📎🎯 컨트롤러 downloadAttachment 진입');
const { accountId, seqno, index } = req.params; const { accountId, seqno, index } = req.params;
console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
const seqnoNumber = parseInt(seqno, 10); const seqnoNumber = parseInt(seqno, 10);
const indexNumber = parseInt(index, 10); const indexNumber = parseInt(index, 10);
if (isNaN(seqnoNumber) || isNaN(indexNumber)) { if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
console.log('❌ 유효하지 않은 파라미터');
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: '유효하지 않은 파라미터입니다.', message: '유효하지 않은 파라미터입니다.',
}); });
} }
console.log('📎 서비스 호출 시작...');
const result = await this.mailReceiveService.downloadAttachment( const result = await this.mailReceiveService.downloadAttachment(
accountId, accountId,
seqnoNumber, seqnoNumber,
indexNumber indexNumber
); );
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
if (!result) { if (!result) {
console.log('❌ 첨부파일을 찾을 수 없음');
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: '첨부파일을 찾을 수 없습니다.', message: '첨부파일을 찾을 수 없습니다.',
}); });
} }
console.log(`📎 파일 다운로드 시작: ${result.filename}`);
console.log(`📎 파일 경로: ${result.filePath}`);
// 파일 다운로드 // 파일 다운로드
res.download(result.filePath, result.filename, (err) => { res.download(result.filePath, result.filename, (err) => {
@ -173,5 +195,27 @@ export class MailReceiveBasicController {
}); });
} }
} }
/**
* GET /api/mail/receive/today-count
*
*/
async getTodayReceivedCount(req: Request, res: Response) {
try {
const { accountId } = req.query;
const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
return res.json({
success: true,
data: { count }
});
} catch (error: unknown) {
console.error('오늘 수신 메일 수 조회 실패:', error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
});
}
}
} }

View File

@ -3,12 +3,31 @@ import { mailSendSimpleService } from '../services/mailSendSimpleService';
export class MailSendSimpleController { export class MailSendSimpleController {
/** /**
* ( ) * ( ) -
*/ */
async sendMail(req: Request, res: Response) { async sendMail(req: Request, res: Response) {
try { try {
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject }); console.log('📧 메일 발송 요청 수신:', {
const { accountId, templateId, to, subject, variables, customHtml } = req.body; accountId: req.body.accountId,
to: req.body.to,
cc: req.body.cc,
bcc: req.body.bcc,
subject: req.body.subject,
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
});
// FormData에서 JSON 문자열 파싱
const accountId = req.body.accountId;
const templateId = req.body.templateId;
const modifiedTemplateComponents = req.body.modifiedTemplateComponents
? JSON.parse(req.body.modifiedTemplateComponents)
: undefined; // 🎯 수정된 템플릿 컴포넌트
const to = req.body.to ? JSON.parse(req.body.to) : [];
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
const subject = req.body.subject;
const variables = req.body.variables ? JSON.parse(req.body.variables) : undefined;
const customHtml = req.body.customHtml;
// 필수 파라미터 검증 // 필수 파라미터 검증
if (!accountId || !to || !Array.isArray(to) || to.length === 0) { if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
@ -34,14 +53,54 @@ export class MailSendSimpleController {
}); });
} }
// 첨부파일 처리 (한글 파일명 지원)
const attachments: Array<{ filename: string; path: string; contentType?: string }> = [];
if (req.files && Array.isArray(req.files)) {
const files = req.files as Express.Multer.File[];
// 프론트엔드에서 전송한 정규화된 파일명 사용 (한글-분석.txt 방식)
let parsedFileNames: string[] = [];
if (req.body.fileNames) {
try {
parsedFileNames = JSON.parse(req.body.fileNames);
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
} catch (e) {
console.warn('파일명 파싱 실패, multer originalname 사용');
}
}
files.forEach((file, index) => {
// 클라이언트에서 전송한 파일명 우선 사용, 없으면 multer의 originalname 사용
let originalName = parsedFileNames[index] || file.originalname;
// NFC 정규화 확실히 수행
originalName = originalName.normalize('NFC');
attachments.push({
filename: originalName,
path: file.path,
contentType: file.mimetype,
});
});
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
filename: a.filename,
path: a.path.split('/').pop()
})));
}
// 메일 발송 // 메일 발송
const result = await mailSendSimpleService.sendMail({ const result = await mailSendSimpleService.sendMail({
accountId, accountId,
templateId, templateId,
modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
to, to,
cc,
bcc,
subject, subject,
variables, variables,
customHtml, customHtml,
attachments: attachments.length > 0 ? attachments : undefined,
}); });
if (result.success) { if (result.success) {

View File

@ -0,0 +1,140 @@
import { Request, Response } from 'express';
import { mailSentHistoryService } from '../services/mailSentHistoryService';
export class MailSentHistoryController {
/**
*
*/
async getList(req: Request, res: Response) {
try {
const query = {
page: req.query.page ? parseInt(req.query.page as string) : undefined,
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
searchTerm: req.query.searchTerm as string | undefined,
status: req.query.status as 'success' | 'failed' | 'all' | undefined,
accountId: req.query.accountId as string | undefined,
startDate: req.query.startDate as string | undefined,
endDate: req.query.endDate as string | undefined,
sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined,
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
};
const result = await mailSentHistoryService.getSentMailList(query);
return res.json({
success: true,
data: result,
});
} catch (error: unknown) {
const err = error as Error;
console.error('발송 이력 목록 조회 실패:', err);
return res.status(500).json({
success: false,
message: '발송 이력 조회 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async getById(req: Request, res: Response) {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: '발송 이력 ID가 필요합니다.',
});
}
const history = await mailSentHistoryService.getSentMailById(id);
if (!history) {
return res.status(404).json({
success: false,
message: '발송 이력을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
data: history,
});
} catch (error: unknown) {
const err = error as Error;
console.error('발송 이력 조회 실패:', err);
return res.status(500).json({
success: false,
message: '발송 이력 조회 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async deleteById(req: Request, res: Response) {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: '발송 이력 ID가 필요합니다.',
});
}
const success = await mailSentHistoryService.deleteSentMail(id);
if (!success) {
return res.status(404).json({
success: false,
message: '발송 이력을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
message: '발송 이력이 삭제되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
console.error('발송 이력 삭제 실패:', err);
return res.status(500).json({
success: false,
message: '발송 이력 삭제 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async getStatistics(req: Request, res: Response) {
try {
const accountId = req.query.accountId as string | undefined;
const stats = await mailSentHistoryService.getStatistics(accountId);
return res.json({
success: true,
data: stats,
});
} catch (error: unknown) {
const err = error as Error;
console.error('통계 조회 실패:', err);
return res.status(500).json({
success: false,
message: '통계 조회 중 오류가 발생했습니다.',
error: err.message,
});
}
}
}
export const mailSentHistoryController = new MailSentHistoryController();

View File

@ -0,0 +1,539 @@
/**
*
*/
import { Request, Response, NextFunction } from "express";
import reportService from "../services/reportService";
import {
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
CreateTemplateRequest,
} from "../types/report";
import path from "path";
import fs from "fs";
export class ReportController {
/**
*
* GET /api/admin/reports
*/
async getReports(req: Request, res: Response, next: NextFunction) {
try {
const {
page = "1",
limit = "20",
searchText = "",
reportType = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
} = req.query;
const result = await reportService.getReports({
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
searchText: searchText as string,
reportType: reportType as string,
useYn: useYn as string,
sortBy: sortBy as string,
sortOrder: sortOrder as "ASC" | "DESC",
});
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
}
/**
*
* GET /api/admin/reports/:reportId
*/
async getReportById(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const report = await reportService.getReportById(reportId);
if (!report) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: report,
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports
*/
async createReport(req: Request, res: Response, next: NextFunction) {
try {
const data: CreateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({
success: false,
message: "리포트명과 리포트 타입은 필수입니다.",
});
}
const reportId = await reportService.createReport(data, userId);
return res.status(201).json({
success: true,
data: {
reportId,
},
message: "리포트가 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* PUT /api/admin/reports/:reportId
*/
async updateReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const data: UpdateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const success = await reportService.updateReport(reportId, data, userId);
if (!success) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
return res.json({
success: true,
message: "리포트가 수정되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* DELETE /api/admin/reports/:reportId
*/
async deleteReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const success = await reportService.deleteReport(reportId);
if (!success) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "리포트가 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports/:reportId/copy
*/
async copyReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const userId = (req as any).user?.userId || "SYSTEM";
const newReportId = await reportService.copyReport(reportId, userId);
if (!newReportId) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.status(201).json({
success: true,
data: {
reportId: newReportId,
},
message: "리포트가 복사되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* GET /api/admin/reports/:reportId/layout
*/
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const layout = await reportService.getLayout(reportId);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
// components JSON 파싱
const layoutData = {
...layout,
components: layout.components ? JSON.parse(layout.components) : [],
};
return res.json({
success: true,
data: layoutData,
});
} catch (error) {
return next(error);
}
}
/**
*
* PUT /api/admin/reports/:reportId/layout
*/
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const data: SaveLayoutRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (
!data.canvasWidth ||
!data.canvasHeight ||
!data.pageOrientation ||
!data.components
) {
return res.status(400).json({
success: false,
message: "필수 레이아웃 정보가 누락되었습니다.",
});
}
await reportService.saveLayout(reportId, data, userId);
return res.json({
success: true,
message: "레이아웃이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await reportService.getTemplates();
return res.json({
success: true,
data: templates,
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* POST /api/admin/reports/templates
*/
async createTemplate(req: Request, res: Response, next: NextFunction) {
try {
const data: CreateTemplateRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.templateNameKor || !data.templateType) {
return res.status(400).json({
success: false,
message: "템플릿명과 템플릿 타입은 필수입니다.",
});
}
const templateId = await reportService.createTemplate(data, userId);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
const templateId = await reportService.saveAsTemplate(
reportId,
templateNameKor,
templateNameEng,
description,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿 ( )
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout(
req: Request,
res: Response,
next: NextFunction
) {
try {
const {
templateNameKor,
templateNameEng,
templateType,
description,
layoutConfig,
defaultQueries = [],
} = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "레이아웃 설정은 필수입니다.",
});
}
const templateId = await reportService.createTemplateFromLayout(
templateNameKor,
templateNameEng,
templateType || "GENERAL",
description,
layoutConfig,
defaultQueries,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* DELETE /api/admin/reports/templates/:templateId
*/
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const success = await reportService.deleteTemplate(templateId);
if (!success) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
});
}
return res.json({
success: true,
message: "템플릿이 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId,
queryId,
parameters,
sqlQuery,
externalConnectionId
);
return res.json({
success: true,
data: result,
});
} catch (error: any) {
return res.status(400).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
}
}
/**
* DB ( )
* GET /api/admin/reports/external-connections
*/
async getExternalConnections(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { ExternalDbConnectionService } = await import(
"../services/externalDbConnectionService"
);
const result = await ExternalDbConnectionService.getConnections({
is_active: "Y",
company_code: req.body.companyCode || "",
});
return res.json(result);
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports/upload-image
*/
async uploadImage(req: Request, res: Response, next: NextFunction) {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: "이미지 파일이 필요합니다.",
});
}
const companyCode = req.body.companyCode || "SYSTEM";
const file = req.file;
// 파일 저장 경로 생성
const uploadDir = path.join(
process.cwd(),
"uploads",
`company_${companyCode}`,
"reports"
);
// 디렉토리가 없으면 생성
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
const timestamp = Date.now();
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${timestamp}_${safeFileName}`;
const filePath = path.join(uploadDir, fileName);
// 파일 저장
fs.writeFileSync(filePath, file.buffer);
// 웹에서 접근 가능한 URL 반환
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
return res.json({
success: true,
data: {
fileName,
fileUrl,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
},
});
} catch (error) {
return next(error);
}
}
}
export default new ReportController();

View File

@ -12,20 +12,29 @@ const router = express.Router();
router.use(authenticateToken); router.use(authenticateToken);
const controller = new MailReceiveBasicController(); const controller = new MailReceiveBasicController();
// 메일 목록 조회 // 오늘 수신 메일 수 조회 (통계) - 가장 먼저 정의 (가장 구체적)
router.get('/:accountId', (req, res) => controller.getMailList(req, res)); router.get('/today-count', (req, res) => controller.getTodayReceivedCount(req, res));
// 메일 상세 조회 // 첨부파일 다운로드 - 매우 구체적인 경로
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res)); router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
console.log(`📎 첨부파일 라우트 핸들러 진입!`);
console.log(` accountId: ${req.params.accountId}`);
console.log(` seqno: ${req.params.seqno}`);
console.log(` index: ${req.params.index}`);
controller.downloadAttachment(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)); router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
// IMAP 연결 테스트 // 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
// IMAP 연결 테스트 - /:accountId보다 먼저 정의해야 함
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res)); router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
// 메일 목록 조회 - 가장 마지막에 정의 (가장 일반적)
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
export default router; export default router;

View File

@ -1,14 +1,19 @@
import { Router } from 'express'; import { Router } from 'express';
import { mailSendSimpleController } from '../controllers/mailSendSimpleController'; import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
import { authenticateToken } from '../middleware/authMiddleware'; import { authenticateToken } from '../middleware/authMiddleware';
import { uploadMailAttachment } from '../config/multerConfig';
const router = Router(); const router = Router();
// 모든 메일 발송 라우트에 인증 미들웨어 적용 // 모든 메일 발송 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
// POST /api/mail/send/simple - 메일 발송 // POST /api/mail/send/simple - 메일 발송 (첨부파일 지원)
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res)); router.post(
'/simple',
uploadMailAttachment.array('attachments', 5), // 최대 5개 파일
(req, res) => mailSendSimpleController.sendMail(req, res)
);
// POST /api/mail/send/test-connection - SMTP 연결 테스트 // POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res)); router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));

View File

@ -0,0 +1,23 @@
import { Router } from 'express';
import { mailSentHistoryController } from '../controllers/mailSentHistoryController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// GET /api/mail/sent - 발송 이력 목록 조회
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
// GET /api/mail/sent/statistics - 통계 조회
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
// DELETE /api/mail/sent/:id - 발송 이력 삭제
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
export default router;

View File

@ -0,0 +1,107 @@
import { Router } from "express";
import reportController from "../controllers/reportController";
import { authenticateToken } from "../middleware/authMiddleware";
import multer from "multer";
const router = Router();
// Multer 설정 (메모리 저장)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한
},
fileFilter: (req, file, cb) => {
// 이미지 파일만 허용
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("이미지 파일만 업로드 가능합니다. (jpg, png, gif, webp)"));
}
},
});
// 모든 리포트 API는 인증이 필요
router.use(authenticateToken);
// 외부 DB 연결 목록 (구체적인 경로를 먼저 배치)
router.get("/external-connections", (req, res, next) =>
reportController.getExternalConnections(req, res, next)
);
// 템플릿 관련 라우트
router.get("/templates", (req, res, next) =>
reportController.getTemplates(req, res, next)
);
router.post("/templates", (req, res, next) =>
reportController.createTemplate(req, res, next)
);
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
router.post("/templates/create-from-layout", (req, res, next) =>
reportController.createTemplateFromLayout(req, res, next)
);
router.delete("/templates/:templateId", (req, res, next) =>
reportController.deleteTemplate(req, res, next)
);
// 이미지 업로드 (구체적인 경로를 먼저 배치)
router.post("/upload-image", upload.single("image"), (req, res, next) =>
reportController.uploadImage(req, res, next)
);
// 리포트 목록
router.get("/", (req, res, next) =>
reportController.getReports(req, res, next)
);
// 리포트 생성
router.post("/", (req, res, next) =>
reportController.createReport(req, res, next)
);
// 리포트 복사 (구체적인 경로를 먼저 배치)
router.post("/:reportId/copy", (req, res, next) =>
reportController.copyReport(req, res, next)
);
// 템플릿으로 저장
router.post("/:reportId/save-as-template", (req, res, next) =>
reportController.saveAsTemplate(req, res, next)
);
// 레이아웃 관련 라우트
router.get("/:reportId/layout", (req, res, next) =>
reportController.getLayout(req, res, next)
);
router.put("/:reportId/layout", (req, res, next) =>
reportController.saveLayout(req, res, next)
);
// 쿼리 실행
router.post("/:reportId/queries/:queryId/execute", (req, res, next) =>
reportController.executeQuery(req, res, next)
);
// 리포트 상세
router.get("/:reportId", (req, res, next) =>
reportController.getReportById(req, res, next)
);
// 리포트 수정
router.put("/:reportId", (req, res, next) =>
reportController.updateReport(req, res, next)
);
// 리포트 삭제
router.delete("/:reportId", (req, res, next) =>
reportController.deleteReport(req, res, next)
);
export default router;

View File

@ -1,6 +1,6 @@
import fs from 'fs/promises'; import fs from "fs/promises";
import path from 'path'; import path from "path";
import { encryptionService } from './encryptionService'; import { encryptionService } from "./encryptionService";
export interface MailAccount { export interface MailAccount {
id: string; id: string;
@ -12,7 +12,7 @@ export interface MailAccount {
smtpUsername: string; smtpUsername: string;
smtpPassword: string; // 암호화된 비밀번호 smtpPassword: string; // 암호화된 비밀번호
dailyLimit: number; dailyLimit: number;
status: 'active' | 'inactive' | 'suspended'; status: "active" | "inactive" | "suspended";
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -21,7 +21,11 @@ class MailAccountFileService {
private accountsDir: string; private accountsDir: string;
constructor() { constructor() {
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts'); // 운영 환경에서는 /app/uploads/mail-accounts, 개발 환경에서는 프로젝트 루트
this.accountsDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-accounts"
: path.join(process.cwd(), "uploads", "mail-accounts");
this.ensureDirectoryExists(); this.ensureDirectoryExists();
} }
@ -29,7 +33,11 @@ class MailAccountFileService {
try { try {
await fs.access(this.accountsDir); await fs.access(this.accountsDir);
} catch { } catch {
await fs.mkdir(this.accountsDir, { recursive: true }); try {
await fs.mkdir(this.accountsDir, { recursive: true });
} catch (error) {
console.error("메일 계정 디렉토리 생성 실패:", error);
}
} }
} }
@ -39,23 +47,24 @@ class MailAccountFileService {
async getAllAccounts(): Promise<MailAccount[]> { async getAllAccounts(): Promise<MailAccount[]> {
await this.ensureDirectoryExists(); await this.ensureDirectoryExists();
try { try {
const files = await fs.readdir(this.accountsDir); const files = await fs.readdir(this.accountsDir);
const jsonFiles = files.filter(f => f.endsWith('.json')); const jsonFiles = files.filter((f) => f.endsWith(".json"));
const accounts = await Promise.all( const accounts = await Promise.all(
jsonFiles.map(async (file) => { jsonFiles.map(async (file) => {
const content = await fs.readFile( const content = await fs.readFile(
path.join(this.accountsDir, file), path.join(this.accountsDir, file),
'utf-8' "utf-8"
); );
return JSON.parse(content) as MailAccount; return JSON.parse(content) as MailAccount;
}) })
); );
return accounts.sort((a, b) => return accounts.sort(
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
} catch { } catch {
return []; return [];
@ -64,7 +73,7 @@ class MailAccountFileService {
async getAccountById(id: string): Promise<MailAccount | null> { async getAccountById(id: string): Promise<MailAccount | null> {
try { try {
const content = await fs.readFile(this.getAccountPath(id), 'utf-8'); const content = await fs.readFile(this.getAccountPath(id), "utf-8");
return JSON.parse(content); return JSON.parse(content);
} catch { } catch {
return null; return null;
@ -72,7 +81,7 @@ class MailAccountFileService {
} }
async createAccount( async createAccount(
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'> data: Omit<MailAccount, "id" | "createdAt" | "updatedAt">
): Promise<MailAccount> { ): Promise<MailAccount> {
const id = `account-${Date.now()}`; const id = `account-${Date.now()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -91,7 +100,7 @@ class MailAccountFileService {
await fs.writeFile( await fs.writeFile(
this.getAccountPath(id), this.getAccountPath(id),
JSON.stringify(account, null, 2), JSON.stringify(account, null, 2),
'utf-8' "utf-8"
); );
return account; return account;
@ -99,7 +108,7 @@ class MailAccountFileService {
async updateAccount( async updateAccount(
id: string, id: string,
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>> data: Partial<Omit<MailAccount, "id" | "createdAt">>
): Promise<MailAccount | null> { ): Promise<MailAccount | null> {
const existing = await this.getAccountById(id); const existing = await this.getAccountById(id);
if (!existing) { if (!existing) {
@ -122,7 +131,7 @@ class MailAccountFileService {
await fs.writeFile( await fs.writeFile(
this.getAccountPath(id), this.getAccountPath(id),
JSON.stringify(updated, null, 2), JSON.stringify(updated, null, 2),
'utf-8' "utf-8"
); );
return updated; return updated;
@ -139,12 +148,12 @@ class MailAccountFileService {
async getAccountByEmail(email: string): Promise<MailAccount | null> { async getAccountByEmail(email: string): Promise<MailAccount | null> {
const accounts = await this.getAllAccounts(); const accounts = await this.getAllAccounts();
return accounts.find(a => a.email === email) || null; return accounts.find((a) => a.email === email) || null;
} }
async getActiveAccounts(): Promise<MailAccount[]> { async getActiveAccounts(): Promise<MailAccount[]> {
const accounts = await this.getAllAccounts(); const accounts = await this.getAllAccounts();
return accounts.filter(a => a.status === 'active'); return accounts.filter((a) => a.status === "active");
} }
/** /**
@ -156,4 +165,3 @@ class MailAccountFileService {
} }
export const mailAccountFileService = new MailAccountFileService(); export const mailAccountFileService = new MailAccountFileService();

View File

@ -3,11 +3,13 @@
* IMAP * IMAP
*/ */
import * as Imap from 'imap'; // CommonJS 모듈이므로 require 사용
import { simpleParser } from 'mailparser'; const Imap = require("imap");
import { mailAccountFileService } from './mailAccountFileService'; import { simpleParser } from "mailparser";
import fs from 'fs/promises'; import { mailAccountFileService } from "./mailAccountFileService";
import path from 'path'; import { encryptionService } from "./encryptionService";
import fs from "fs/promises";
import path from "path";
export interface ReceivedMail { export interface ReceivedMail {
id: string; id: string;
@ -45,7 +47,11 @@ export class MailReceiveBasicService {
private attachmentsDir: string; private attachmentsDir: string;
constructor() { constructor() {
this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments'); // 운영 환경에서는 /app/uploads/mail-attachments, 개발 환경에서는 프로젝트 루트
this.attachmentsDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-attachments"
: path.join(process.cwd(), "uploads", "mail-attachments");
this.ensureDirectoryExists(); this.ensureDirectoryExists();
} }
@ -53,10 +59,28 @@ export class MailReceiveBasicService {
try { try {
await fs.access(this.attachmentsDir); await fs.access(this.attachmentsDir);
} catch { } catch {
await fs.mkdir(this.attachmentsDir, { recursive: true }); try {
await fs.mkdir(this.attachmentsDir, { recursive: true });
} catch (error) {
console.error("메일 첨부파일 디렉토리 생성 실패:", error);
}
} }
} }
/**
* SMTP IMAP
*/
private inferImapPort(smtpPort: number, imapPort?: number): number {
if (imapPort) return imapPort;
if (smtpPort === 465 || smtpPort === 587) {
return 993; // IMAPS (SSL/TLS)
} else if (smtpPort === 25) {
return 143; // IMAP (no encryption)
}
return 993; // 기본값: IMAPS
}
/** /**
* IMAP * IMAP
*/ */
@ -74,33 +98,56 @@ export class MailReceiveBasicService {
/** /**
* *
*/ */
async fetchMailList(accountId: string, limit: number = 50): Promise<ReceivedMail[]> { async fetchMailList(
accountId: string,
limit: number = 50
): Promise<ReceivedMail[]> {
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.'); throw new Error("메일 계정을 찾을 수 없습니다.");
} }
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// IMAP 설정
const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: account.smtpPassword, // 이미 복호화됨 password: decryptedPassword,
host: account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993 port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
// console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
const mails: ReceivedMail[] = []; const mails: ReceivedMail[] = [];
imap.once('ready', () => { // 30초 타임아웃 설정
imap.openBox('INBOX', true, (err: any, box: any) => { const timeout = setTimeout(() => {
// console.error('❌ IMAP 연결 타임아웃 (30초)');
imap.end();
reject(new Error("IMAP 연결 타임아웃"));
}, 30000);
imap.once("ready", () => {
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout);
imap.openBox("INBOX", true, (err: any, box: any) => {
if (err) { if (err) {
// console.error('❌ INBOX 열기 실패:', err);
imap.end(); imap.end();
return reject(err); return reject(err);
} }
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total; const totalMessages = box.messages.total;
if (totalMessages === 0) { if (totalMessages === 0) {
// console.log('📭 메일함이 비어있습니다');
imap.end(); imap.end();
return resolve([]); return resolve([]);
} }
@ -109,78 +156,133 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1); const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages; const end = totalMessages;
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, { const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'], bodies: ["HEADER", "TEXT"],
struct: true, struct: true,
}); });
fetch.on('message', (msg: any, seqno: any) => { // console.log(`📦 fetch 객체 생성 완료`);
let header: string = '';
let body: string = '';
let attributes: any = null;
msg.on('body', (stream: any, info: any) => { let processedCount = 0;
let buffer = ''; const totalToProcess = end - start + 1;
stream.on('data', (chunk: any) => {
buffer += chunk.toString('utf8'); fetch.on("message", (msg: any, seqno: any) => {
// console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = "";
let body: string = "";
let attributes: any = null;
let bodiesReceived = 0;
msg.on("body", (stream: any, info: any) => {
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
}); });
stream.once('end', () => { stream.once("end", () => {
if (info.which === 'HEADER') { if (info.which === "HEADER") {
header = buffer; header = buffer;
} else { } else {
body = buffer; body = buffer;
} }
bodiesReceived++;
}); });
}); });
msg.once('attributes', (attrs: any) => { msg.once("attributes", (attrs: any) => {
attributes = attrs; attributes = attrs;
}); });
msg.once('end', async () => { msg.once("end", () => {
try { // body 데이터를 모두 받을 때까지 대기
const parsed = await simpleParser(header + '\r\n\r\n' + body); const waitForBodies = setInterval(async () => {
if (bodiesReceived >= 2 || (header && body)) {
clearInterval(waitForBodies);
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; try {
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; const parsed = await simpleParser(
header + "\r\n\r\n" + body
);
const mail: ReceivedMail = { const fromAddress = Array.isArray(parsed.from)
id: `${accountId}-${seqno}`, ? parsed.from[0]
messageId: parsed.messageId || `${seqno}`, : parsed.from;
from: fromAddress?.text || 'Unknown', const toAddress = Array.isArray(parsed.to)
to: toAddress?.text || '', ? parsed.to[0]
subject: parsed.subject || '(제목 없음)', : parsed.to;
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); const mail: ReceivedMail = {
} catch (parseError) { id: `${accountId}-${seqno}`,
console.error('메일 파싱 오류:', parseError); 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);
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
processedCount++;
} catch (parseError) {
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
processedCount++;
}
}
}, 50);
}); });
}); });
fetch.once('error', (fetchErr: any) => { fetch.once("error", (fetchErr: any) => {
// console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end(); imap.end();
reject(fetchErr); reject(fetchErr);
}); });
fetch.once('end', () => { fetch.once("end", () => {
imap.end(); // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
// 최신 메일이 위로 오도록 정렬
mails.sort((a, b) => b.date.getTime() - a.date.getTime()); // 모든 메일 처리가 완료될 때까지 대기
resolve(mails); const checkComplete = setInterval(() => {
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
if (processedCount >= totalToProcess) {
clearInterval(checkComplete);
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
imap.end();
// 최신 메일이 위로 오도록 정렬
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
// console.log(`📤 메일 목록 반환: ${mails.length}개`);
resolve(mails);
}
}, 100);
// 최대 10초 대기
setTimeout(() => {
clearInterval(checkComplete);
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
imap.end();
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
resolve(mails);
}, 10000);
}); });
}); });
}); });
imap.once('error', (imapErr: any) => { imap.once("error", (imapErr: any) => {
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
clearTimeout(timeout);
reject(imapErr); reject(imapErr);
}); });
imap.once("end", () => {
// console.log('🔌 IMAP 연결 종료');
});
// console.log('🔗 IMAP.connect() 호출...');
imap.connect(); imap.connect();
}); });
} }
@ -190,102 +292,169 @@ export class MailReceiveBasicService {
*/ */
private extractPreview(text: string): string { private extractPreview(text: string): string {
// HTML 태그 제거 // HTML 태그 제거
const plainText = text.replace(/<[^>]*>/g, ''); const plainText = text.replace(/<[^>]*>/g, "");
// 공백 정리 // 공백 정리
const cleaned = plainText.replace(/\s+/g, ' ').trim(); const cleaned = plainText.replace(/\s+/g, " ").trim();
// 최대 150자 // 최대 150자
return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned; return cleaned.length > 150 ? cleaned.substring(0, 150) + "..." : cleaned;
} }
/** /**
* *
*/ */
async getMailDetail(accountId: string, seqno: number): Promise<MailDetail | null> { async getMailDetail(
accountId: string,
seqno: number
): Promise<MailDetail | null> {
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.'); throw new Error("메일 계정을 찾을 수 없습니다.");
} }
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: account.smtpPassword, password: decryptedPassword,
host: account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: account.smtpPort === 587 ? 993 : account.smtpPort, port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => { imap.once("ready", () => {
imap.openBox('INBOX', false, (err: any, box: any) => { imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) { if (err) {
imap.end(); imap.end();
return reject(err); return reject(err);
} }
console.log(
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
);
if (seqno > box.messages.total || seqno < 1) {
console.error(
`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`
);
imap.end();
return resolve(null);
}
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, { const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
bodies: '', bodies: "",
struct: true, struct: true,
}); });
let mailDetail: MailDetail | null = null; let mailDetail: MailDetail | null = null;
let parsingComplete = false;
fetch.on('message', (msg: any, seqnum: any) => { fetch.on("message", (msg: any, seqnum: any) => {
msg.on('body', (stream: any, info: any) => { console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
let buffer = '';
stream.on('data', (chunk: any) => { msg.on("body", (stream: any, info: any) => {
buffer += chunk.toString('utf8'); console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
}); });
stream.once('end', async () => { stream.once("end", async () => {
console.log(
`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
);
try { try {
const parsed = await simpleParser(buffer); const parsed = await simpleParser(buffer);
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; const fromAddress = Array.isArray(parsed.from)
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; ? parsed.from[0]
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc; : parsed.from;
const bccAddress = Array.isArray(parsed.bcc) ? parsed.bcc[0] : parsed.bcc; 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 = { mailDetail = {
id: `${accountId}-${seqnum}`, id: `${accountId}-${seqnum}`,
messageId: parsed.messageId || `${seqnum}`, messageId: parsed.messageId || `${seqnum}`,
from: fromAddress?.text || 'Unknown', from: fromAddress?.text || "Unknown",
to: toAddress?.text || '', to: toAddress?.text || "",
cc: ccAddress?.text, cc: ccAddress?.text,
bcc: bccAddress?.text, bcc: bccAddress?.text,
subject: parsed.subject || '(제목 없음)', subject: parsed.subject || "(제목 없음)",
date: parsed.date || new Date(), date: parsed.date || new Date(),
htmlBody: parsed.html || '', htmlBody: parsed.html || "",
textBody: parsed.text || '', textBody: parsed.text || "",
preview: this.extractPreview(parsed.text || parsed.html || ''), preview: this.extractPreview(
parsed.text || parsed.html || ""
),
isRead: true, // 조회 시 읽음으로 표시 isRead: true, // 조회 시 읽음으로 표시
hasAttachments: (parsed.attachments?.length || 0) > 0, hasAttachments: (parsed.attachments?.length || 0) > 0,
attachments: (parsed.attachments || []).map((att: any) => ({ attachments: (parsed.attachments || []).map((att: any) => ({
filename: att.filename || 'unnamed', filename: att.filename || "unnamed",
contentType: att.contentType || 'application/octet-stream', contentType:
att.contentType || "application/octet-stream",
size: att.size || 0, size: att.size || 0,
})), })),
}; };
parsingComplete = true;
} catch (parseError) { } catch (parseError) {
console.error('메일 파싱 오류:', parseError); console.error("메일 파싱 오류:", parseError);
parsingComplete = true;
} }
}); });
}); });
// msg 전체가 처리되었을 때 이벤트
msg.once("end", () => {
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
});
}); });
fetch.once('error', (fetchErr: any) => { fetch.once("error", (fetchErr: any) => {
console.error(`❌ Fetch 에러:`, fetchErr);
imap.end(); imap.end();
reject(fetchErr); reject(fetchErr);
}); });
fetch.once('end', () => { fetch.once("end", () => {
imap.end(); console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
resolve(mailDetail);
// 비동기 파싱이 완료될 때까지 대기
const waitForParsing = setInterval(() => {
if (parsingComplete) {
clearInterval(waitForParsing);
console.log(
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
);
imap.end();
resolve(mailDetail);
}
}, 10); // 10ms마다 체크
// 타임아웃 설정 (10초)
setTimeout(() => {
if (!parsingComplete) {
clearInterval(waitForParsing);
console.error("❌ 파싱 타임아웃");
imap.end();
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
}
}, 10000);
}); });
}); });
}); });
imap.once('error', (imapErr: any) => { imap.once("error", (imapErr: any) => {
reject(imapErr); reject(imapErr);
}); });
@ -296,45 +465,52 @@ export class MailReceiveBasicService {
/** /**
* *
*/ */
async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> { async markAsRead(
accountId: string,
seqno: number
): Promise<{ success: boolean; message: string }> {
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.'); throw new Error("메일 계정을 찾을 수 없습니다.");
} }
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: account.smtpPassword, password: decryptedPassword,
host: account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: account.smtpPort === 587 ? 993 : account.smtpPort, port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => { imap.once("ready", () => {
imap.openBox('INBOX', false, (err: any, box: any) => { imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) { if (err) {
imap.end(); imap.end();
return reject(err); return reject(err);
} }
imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => { imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
imap.end(); imap.end();
if (flagErr) { if (flagErr) {
reject(flagErr); reject(flagErr);
} else { } else {
resolve({ resolve({
success: true, success: true,
message: '메일을 읽음으로 표시했습니다.', message: "메일을 읽음으로 표시했습니다.",
}); });
} }
}); });
}); });
}); });
imap.once('error', (imapErr: any) => { imap.once("error", (imapErr: any) => {
reject(imapErr); reject(imapErr);
}); });
@ -345,43 +521,51 @@ export class MailReceiveBasicService {
/** /**
* IMAP * IMAP
*/ */
async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> { async testImapConnection(
accountId: string
): Promise<{ success: boolean; message: string }> {
try { try {
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.'); throw new Error("메일 계정을 찾을 수 없습니다.");
} }
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: account.smtpPassword, password: decryptedPassword,
host: account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: account.smtpPort === 587 ? 993 : account.smtpPort, port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => { imap.once("ready", () => {
imap.end(); imap.end();
resolve({ resolve({
success: true, success: true,
message: 'IMAP 연결 성공', message: "IMAP 연결 성공",
}); });
}); });
imap.once('error', (err: any) => { imap.once("error", (err: any) => {
reject(err); reject(err);
}); });
// 타임아웃 설정 (10초) // 타임아웃 설정 (10초)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
imap.end(); imap.end();
reject(new Error('연결 시간 초과')); reject(new Error("연결 시간 초과"));
}, 10000); }, 10000);
imap.once('ready', () => { imap.once("ready", () => {
clearTimeout(timeout); clearTimeout(timeout);
}); });
@ -390,11 +574,48 @@ export class MailReceiveBasicService {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: error instanceof Error ? error.message : '알 수 없는 오류', message: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
/**
* ()
*/
async getTodayReceivedCount(accountId?: string): Promise<number> {
try {
const accounts = accountId
? [await mailAccountFileService.getAccountById(accountId)]
: await mailAccountFileService.getAllAccounts();
const today = new Date();
today.setHours(0, 0, 0, 0);
let totalCount = 0;
for (const account of accounts) {
if (!account) continue;
try {
const mails = await this.fetchMailList(account.id, 100);
const todayMails = mails.filter((mail) => {
const mailDate = new Date(mail.date);
return mailDate >= today;
});
totalCount += todayMails.length;
} catch (error) {
// 개별 계정 오류는 무시하고 계속 진행
console.error(`계정 ${account.id} 메일 조회 실패:`, error);
}
}
return totalCount;
} catch (error) {
console.error("오늘 수신 메일 수 조회 실패:", error);
return 0;
}
}
/** /**
* *
*/ */
@ -402,50 +623,78 @@ export class MailReceiveBasicService {
accountId: string, accountId: string,
seqno: number, seqno: number,
attachmentIndex: number attachmentIndex: number
): Promise<{ filePath: string; filename: string; contentType: string } | null> { ): Promise<{
filePath: string;
filename: string;
contentType: string;
} | null> {
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.'); throw new Error("메일 계정을 찾을 수 없습니다.");
} }
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: account.smtpPassword, password: decryptedPassword,
host: account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: account.smtpPort === 587 ? 993 : account.smtpPort, port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
imap.once('ready', () => { imap.once("ready", () => {
imap.openBox('INBOX', true, (err: any, box: any) => { imap.openBox("INBOX", true, (err: any, box: any) => {
if (err) { if (err) {
imap.end(); imap.end();
return reject(err); return reject(err);
} }
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, { const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
bodies: '', bodies: "",
struct: true, struct: true,
}); });
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null; let attachmentResult: {
filePath: string;
filename: string;
contentType: string;
} | null = null;
let parsingComplete = false;
fetch.on('message', (msg: any, seqnum: any) => { fetch.on("message", (msg: any, seqnum: any) => {
msg.on('body', (stream: any, info: any) => { console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
let buffer = '';
stream.on('data', (chunk: any) => { msg.on("body", (stream: any, info: any) => {
buffer += chunk.toString('utf8'); console.log(`📎 메일 본문 스트림 시작`);
let buffer = "";
stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8");
}); });
stream.once('end', async () => { stream.once("end", async () => {
console.log(
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
);
try { try {
const parsed = await simpleParser(buffer); const parsed = await simpleParser(buffer);
console.log(
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
);
if (parsed.attachments && parsed.attachments[attachmentIndex]) { if (
parsed.attachments &&
parsed.attachments[attachmentIndex]
) {
const attachment = parsed.attachments[attachmentIndex]; const attachment = parsed.attachments[attachmentIndex];
console.log(
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
);
// 안전한 파일명 생성 // 안전한 파일명 생성
const safeFilename = this.sanitizeFilename( const safeFilename = this.sanitizeFilename(
attachment.filename || `attachment-${Date.now()}` attachment.filename || `attachment-${Date.now()}`
@ -456,33 +705,63 @@ export class MailReceiveBasicService {
// 파일 저장 // 파일 저장
await fs.writeFile(filePath, attachment.content); await fs.writeFile(filePath, attachment.content);
console.log(`📎 파일 저장 완료: ${filePath}`);
attachmentResult = { attachmentResult = {
filePath, filePath,
filename: attachment.filename || 'unnamed', filename: attachment.filename || "unnamed",
contentType: attachment.contentType || 'application/octet-stream', contentType:
attachment.contentType || "application/octet-stream",
}; };
parsingComplete = true;
} else {
console.log(
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
);
parsingComplete = true;
} }
} catch (parseError) { } catch (parseError) {
console.error('첨부파일 파싱 오류:', parseError); console.error("첨부파일 파싱 오류:", parseError);
parsingComplete = true;
} }
}); });
}); });
}); });
fetch.once('error', (fetchErr: any) => { fetch.once("error", (fetchErr: any) => {
console.error("❌ fetch 오류:", fetchErr);
imap.end(); imap.end();
reject(fetchErr); reject(fetchErr);
}); });
fetch.once('end', () => { fetch.once("end", () => {
imap.end(); console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
resolve(attachmentResult);
// 파싱 완료를 기다림 (최대 5초)
const checkComplete = setInterval(() => {
if (parsingComplete) {
console.log(
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
);
clearInterval(checkComplete);
imap.end();
resolve(attachmentResult);
}
}, 100);
setTimeout(() => {
clearInterval(checkComplete);
console.log(
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
);
imap.end();
resolve(attachmentResult);
}, 5000);
}); });
}); });
}); });
imap.once('error', (imapErr: any) => { imap.once("error", (imapErr: any) => {
reject(imapErr); reject(imapErr);
}); });
@ -495,9 +774,8 @@ export class MailReceiveBasicService {
*/ */
private sanitizeFilename(filename: string): string { private sanitizeFilename(filename: string): string {
return filename return filename
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_') .replace(/[^a-zA-Z0-9가-힣.\-_]/g, "_")
.replace(/_{2,}/g, '_') .replace(/_{2,}/g, "_")
.substring(0, 200); // 최대 길이 제한 .substring(0, 200); // 최대 길이 제한
} }
} }

View File

@ -7,14 +7,23 @@ import nodemailer from 'nodemailer';
import { mailAccountFileService } from './mailAccountFileService'; import { mailAccountFileService } from './mailAccountFileService';
import { mailTemplateFileService } from './mailTemplateFileService'; import { mailTemplateFileService } from './mailTemplateFileService';
import { encryptionService } from './encryptionService'; import { encryptionService } from './encryptionService';
import { mailSentHistoryService } from './mailSentHistoryService';
export interface SendMailRequest { export interface SendMailRequest {
accountId: string; accountId: string;
templateId?: string; templateId?: string;
to: string[]; // 수신자 이메일 배열 modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
to: string[]; // 받는 사람
cc?: string[]; // 참조 (Carbon Copy)
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
subject: string; subject: string;
variables?: Record<string, string>; // 템플릿 변수 치환 variables?: Record<string, string>; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시 customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
attachments?: Array<{ // 첨부파일
filename: string;
path: string;
contentType?: string;
}>;
} }
export interface SendMailResult { export interface SendMailResult {
@ -30,6 +39,8 @@ class MailSendSimpleService {
* *
*/ */
async sendMail(request: SendMailRequest): Promise<SendMailResult> { async sendMail(request: SendMailRequest): Promise<SendMailResult> {
let htmlContent = ''; // 상위 스코프로 이동
try { try {
// 1. 계정 조회 // 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId); const account = await mailAccountFileService.getAccountById(request.accountId);
@ -42,15 +53,29 @@ class MailSendSimpleService {
throw new Error('비활성 상태의 계정입니다.'); throw new Error('비활성 상태의 계정입니다.');
} }
// 3. HTML 생성 (템플릿 또는 커스텀) // 3. HTML 생성 (템플릿 + 추가 메시지 병합)
let htmlContent = request.customHtml || ''; if (request.templateId) {
// 템플릿 사용
if (!htmlContent && request.templateId) {
const template = await mailTemplateFileService.getTemplateById(request.templateId); const template = await mailTemplateFileService.getTemplateById(request.templateId);
if (!template) { if (!template) {
throw new Error('템플릿을 찾을 수 없습니다.'); throw new Error('템플릿을 찾을 수 없습니다.');
} }
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
template.components = request.modifiedTemplateComponents;
}
htmlContent = this.renderTemplate(template, request.variables); htmlContent = this.renderTemplate(template, request.variables);
// 템플릿 + 추가 메시지 병합
if (request.customHtml && request.customHtml.trim()) {
htmlContent = this.mergeTemplateAndCustomContent(htmlContent, request.customHtml);
}
} else {
// 직접 작성
htmlContent = request.customHtml || '';
} }
if (!htmlContent) { if (!htmlContent) {
@ -59,20 +84,20 @@ class MailSendSimpleService {
// 4. 비밀번호 복호화 // 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
console.log('🔐 비밀번호 복호화 완료'); // console.log('🔐 비밀번호 복호화 완료');
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 5. SMTP 연결 생성 // 5. SMTP 연결 생성
// 포트 465는 SSL/TLS를 사용해야 함 // 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
console.log('📧 SMTP 연결 설정:', { // console.log('📧 SMTP 연결 설정:', {
host: account.smtpHost, // host: account.smtpHost,
port: account.smtpPort, // port: account.smtpPort,
secure: isSecure, // secure: isSecure,
user: account.smtpUsername, // user: account.smtpUsername,
}); // });
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: account.smtpHost, host: account.smtpHost,
@ -89,13 +114,60 @@ class MailSendSimpleService {
console.log('📧 메일 발송 시도 중...'); console.log('📧 메일 발송 시도 중...');
// 6. 메일 발송 // 6. 메일 발송 (CC, BCC, 첨부파일 지원)
const info = await transporter.sendMail({ const mailOptions: any = {
from: `"${account.name}" <${account.email}>`, from: `"${account.name}" <${account.email}>`,
to: request.to.join(', '), to: request.to.join(', '),
subject: this.replaceVariables(request.subject, request.variables), subject: this.replaceVariables(request.subject, request.variables),
html: htmlContent, html: htmlContent,
}); };
// 참조(CC) 추가
if (request.cc && request.cc.length > 0) {
mailOptions.cc = request.cc.join(', ');
// console.log('📧 참조(CC):', request.cc);
}
// 숨은참조(BCC) 추가
if (request.bcc && request.bcc.length > 0) {
mailOptions.bcc = request.bcc.join(', ');
// console.log('🔒 숨은참조(BCC):', request.bcc);
}
// 첨부파일 추가 (한글 파일명 인코딩 처리)
if (request.attachments && request.attachments.length > 0) {
mailOptions.attachments = request.attachments.map(att => {
// 파일명에서 타임스탬프_랜덤숫자_ 부분 제거하여 원본 파일명 복원
let filename = att.filename.replace(/^\d+-\d+_/, '');
// NFC 정규화 (한글 조합 문자 정규화)
filename = filename.normalize('NFC');
// ISO-8859-1 호환을 위한 안전한 파일명 생성
// 한글이 포함된 경우 RFC 2047 MIME 인코딩 사용
const hasKorean = /[\uAC00-\uD7AF]/.test(filename);
let safeFilename = filename;
if (hasKorean) {
// 한글이 포함된 경우: RFC 2047 MIME 인코딩 사용
safeFilename = `=?UTF-8?B?${Buffer.from(filename, 'utf8').toString('base64')}?=`;
}
return {
filename: safeFilename,
path: att.path,
contentType: att.contentType,
// 다중 호환성을 위한 헤더 설정
headers: {
'Content-Disposition': `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
};
});
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
}
const info = await transporter.sendMail(mailOptions);
console.log('✅ 메일 발송 성공:', { console.log('✅ 메일 발송 성공:', {
messageId: info.messageId, messageId: info.messageId,
@ -103,6 +175,43 @@ class MailSendSimpleService {
rejected: info.rejected, rejected: info.rejected,
}); });
// 발송 이력 저장 (성공)
try {
const template = request.templateId
? await mailTemplateFileService.getTemplateById(request.templateId)
: undefined;
// AttachmentInfo 형식으로 변환
const attachmentInfos = request.attachments?.map(att => ({
filename: att.filename,
originalName: att.filename,
size: 0, // multer에서 제공하지 않으므로 0으로 설정
path: att.path,
mimetype: att.contentType || 'application/octet-stream',
}));
await mailSentHistoryService.saveSentMail({
accountId: account.id,
accountName: account.name,
accountEmail: account.email,
to: request.to,
cc: request.cc,
bcc: request.bcc,
subject: this.replaceVariables(request.subject, request.variables),
htmlContent,
templateId: request.templateId,
templateName: template?.name,
attachments: attachmentInfos,
status: 'success',
messageId: info.messageId,
accepted: info.accepted as string[],
rejected: info.rejected as string[],
});
} catch (historyError) {
console.error('발송 이력 저장 실패:', historyError);
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
}
return { return {
success: true, success: true,
messageId: info.messageId, messageId: info.messageId,
@ -113,6 +222,52 @@ class MailSendSimpleService {
const err = error as Error; const err = error as Error;
console.error('❌ 메일 발송 실패:', err.message); console.error('❌ 메일 발송 실패:', err.message);
console.error('❌ 에러 상세:', err); console.error('❌ 에러 상세:', err);
// 발송 이력 저장 (실패)
try {
// 계정 정보 가져오기 (실패 시에도 필요)
let accountInfo = { name: 'Unknown', email: 'unknown@example.com' };
try {
const acc = await mailAccountFileService.getAccountById(request.accountId);
if (acc) {
accountInfo = { name: acc.name, email: acc.email };
}
} catch (accError) {
// 계정 조회 실패는 무시
}
const template = request.templateId
? await mailTemplateFileService.getTemplateById(request.templateId)
: undefined;
// AttachmentInfo 형식으로 변환
const attachmentInfos = request.attachments?.map(att => ({
filename: att.filename,
originalName: att.filename,
size: 0,
path: att.path,
mimetype: att.contentType || 'application/octet-stream',
}));
await mailSentHistoryService.saveSentMail({
accountId: request.accountId,
accountName: accountInfo.name,
accountEmail: accountInfo.email,
to: request.to,
cc: request.cc,
bcc: request.bcc,
subject: request.subject,
htmlContent: htmlContent || '',
templateId: request.templateId,
templateName: template?.name,
attachments: attachmentInfos,
status: 'failed',
errorMessage: err.message,
});
} catch (historyError) {
console.error('발송 이력 저장 실패:', historyError);
}
return { return {
success: false, success: false,
error: err.message, error: err.message,
@ -121,13 +276,25 @@ class MailSendSimpleService {
} }
/** /**
* 릿 ( ) * 릿 ( )
*/ */
private renderTemplate( private renderTemplate(
template: any, template: any,
variables?: Record<string, string> variables?: Record<string, string>
): string { ): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">'; // 일반적인 메일 레이아웃 (전체 너비, 그림자 없음)
let html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #ffffff;">
<tr>
<td style="padding: 20px;">
`;
template.components.forEach((component: any) => { template.components.forEach((component: any) => {
switch (component.type) { switch (component.type) {
@ -136,48 +303,51 @@ class MailSendSimpleService {
if (variables) { if (variables) {
content = this.replaceVariables(content, variables); content = this.replaceVariables(content, variables);
} }
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`; // 텍스트는 왼쪽 정렬, 적절한 줄간격
html += `<div style="margin: 0 0 20px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '15px'}; line-height: 1.6; text-align: left;">${content}</div>`;
break; break;
case 'button': case 'button':
let buttonText = component.text || 'Button'; let buttonText = component.text || 'Button';
if (variables) { if (variables) {
buttonText = this.replaceVariables(buttonText, variables); buttonText = this.replaceVariables(buttonText, variables);
} }
html += ` // 버튼은 왼쪽 정렬 (text-align 제거)
<a href="${component.url || '#'}" style=" html += `<div style="margin: 30px 0; text-align: left;">
display: inline-block; <a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
padding: 12px 24px; </div>`;
background-color: ${component.styles?.backgroundColor || '#007bff'};
color: ${component.styles?.color || 'white'};
text-decoration: none;
border-radius: 4px;
${this.styleObjectToString(component.styles)}
">${buttonText}</a>
`;
break; break;
case 'image': case 'image':
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`; // 이미지는 왼쪽 정렬
html += `<div style="margin: 20px 0; text-align: left;">
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto; display: block; border-radius: 4px;" />
</div>`;
break; break;
case 'spacer': case 'spacer':
html += `<div style="height: ${component.height || 20}px;"></div>`; html += `<div style="height: ${component.height || '20px'};"></div>`;
break; break;
} }
}); });
html += '</div>'; html += `
</td>
</tr>
</table>
</body>
</html>
`;
return html; return html;
} }
/** /**
* *
*/ */
private replaceVariables(text: string, variables?: Record<string, string>): string { private replaceVariables(
if (!variables) return text; content: string,
variables?: Record<string, string>
): string {
if (!variables) return content;
let result = text; let result = content;
Object.entries(variables).forEach(([key, value]) => { Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g'); const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, value); result = result.replace(regex, value);
@ -187,20 +357,49 @@ class MailSendSimpleService {
} }
/** /**
* CSS * 릿
* 릿 HTML의 body
*/ */
private styleObjectToString(styles?: Record<string, string>): string { private mergeTemplateAndCustomContent(templateHtml: string, customContent: string): string {
if (!styles) return ''; // customContent에 HTML 태그가 없으면 기본 스타일 적용
return Object.entries(styles) let formattedCustomContent = customContent;
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) if (!customContent.includes('<')) {
.join('; '); // 일반 텍스트인 경우 단락으로 변환
} const paragraphs = customContent
.split('\n\n')
.filter((p) => p.trim())
.map((p) => `<p style="margin: 16px 0; line-height: 1.6;">${p.replace(/\n/g, '<br>')}</p>`)
.join('');
formattedCustomContent = `
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
${paragraphs}
</div>
`;
} else {
// 이미 HTML인 경우 구분선만 추가
formattedCustomContent = `
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
${customContent}
</div>
`;
}
/** // </body> 또는 </div> 태그 앞에 삽입
* camelCase를 kebab-case로 if (templateHtml.includes('</body>')) {
*/ return templateHtml.replace('</body>', `${formattedCustomContent}</body>`);
private camelToKebab(str: string): string { } else if (templateHtml.includes('</div>')) {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); // 마지막 </div> 앞에 삽입
const lastDivIndex = templateHtml.lastIndexOf('</div>');
return (
templateHtml.substring(0, lastDivIndex) +
formattedCustomContent +
templateHtml.substring(lastDivIndex)
);
} else {
// 태그가 없으면 단순 결합
return templateHtml + formattedCustomContent;
}
} }
/** /**
@ -208,26 +407,25 @@ class MailSendSimpleService {
*/ */
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> { async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
try { try {
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
const account = await mailAccountFileService.getAccountById(accountId); const account = await mailAccountFileService.getAccountById(accountId);
if (!account) { if (!account) {
throw new Error('계정을 찾을 수 없습니다.'); return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
} }
// 비밀번호 복호화 // 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
console.log('🔐 비밀번호 복호화 완료'); // console.log('🔐 테스트용 비밀번호 복호화 완료');
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 포트 465는 SSL/TLS를 사용해야 함 // 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
console.log('🔌 SMTP 연결 설정:', { // console.log('🧪 SMTP 연결 테스트 시작:', {
host: account.smtpHost, // host: account.smtpHost,
port: account.smtpPort, // port: account.smtpPort,
secure: isSecure, // secure: isSecure,
user: account.smtpUsername, // user: account.smtpUsername,
}); // });
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: account.smtpHost, host: account.smtpHost,
@ -237,28 +435,22 @@ class MailSendSimpleService {
user: account.smtpUsername, user: account.smtpUsername,
pass: decryptedPassword, // 복호화된 비밀번호 사용 pass: decryptedPassword, // 복호화된 비밀번호 사용
}, },
connectionTimeout: 10000, // 10초 타임아웃 // 테스트용 타임아웃 (10초)
connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
}); });
console.log('🔌 SMTP 연결 검증 중...'); // 연결 테스트
await transporter.verify(); await transporter.verify();
console.log('✅ SMTP 연결 검증 성공!');
console.log('✅ SMTP 연결 테스트 성공');
return { return { success: true, message: 'SMTP 연결이 성공했습니다.' };
success: true,
message: 'SMTP 연결 성공!',
};
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
console.error('❌ SMTP 연결 실패:', err.message); console.error('❌ SMTP 연결 테스트 실패:', err.message);
return { return { success: false, message: `SMTP 연결 실패: ${err.message}` };
success: false,
message: `연결 실패: ${err.message}`,
};
} }
} }
} }
export const mailSendSimpleService = new MailSendSimpleService(); export const mailSendSimpleService = new MailSendSimpleService();

View File

@ -0,0 +1,322 @@
/**
* ( )
*/
import fs from "fs";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import {
SentMailHistory,
SentMailListQuery,
SentMailListResponse,
AttachmentInfo,
} from "../types/mailSentHistory";
// 운영 환경에서는 /app/data/mail-sent, 개발 환경에서는 프로젝트 루트의 data/mail-sent 사용
const SENT_MAIL_DIR =
process.env.NODE_ENV === "production"
? "/app/data/mail-sent"
: path.join(process.cwd(), "data", "mail-sent");
class MailSentHistoryService {
constructor() {
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
try {
if (!fs.existsSync(SENT_MAIL_DIR)) {
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
}
} catch (error) {
console.error("메일 발송 이력 디렉토리 생성 실패:", error);
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
// 실제 파일 쓰기 시점에 에러 처리
}
}
/**
*
*/
async saveSentMail(
data: Omit<SentMailHistory, "id" | "sentAt">
): Promise<SentMailHistory> {
const history: SentMailHistory = {
id: uuidv4(),
sentAt: new Date().toISOString(),
...data,
};
try {
// 디렉토리가 없으면 다시 시도
if (!fs.existsSync(SENT_MAIL_DIR)) {
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
}
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8");
console.log("발송 이력 저장:", history.id);
} catch (error) {
console.error("발송 이력 저장 실패:", error);
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
}
return history;
}
/**
* (, )
*/
async getSentMailList(
query: SentMailListQuery
): Promise<SentMailListResponse> {
const {
page = 1,
limit = 20,
searchTerm = "",
status = "all",
accountId,
startDate,
endDate,
sortBy = "sentAt",
sortOrder = "desc",
} = query;
// 모든 발송 이력 파일 읽기
let allHistory: SentMailHistory[] = [];
try {
// 디렉토리가 없으면 빈 배열 반환
if (!fs.existsSync(SENT_MAIL_DIR)) {
console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
return {
items: [],
total: 0,
page,
limit,
totalPages: 0,
};
}
const files = fs
.readdirSync(SENT_MAIL_DIR)
.filter((f) => f.endsWith(".json"));
for (const file of files) {
try {
const filePath = path.join(SENT_MAIL_DIR, file);
const content = fs.readFileSync(filePath, "utf-8");
const history: SentMailHistory = JSON.parse(content);
allHistory.push(history);
} catch (error) {
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
}
}
} catch (error) {
console.error("메일 발송 이력 조회 실패:", error);
return {
items: [],
total: 0,
page,
limit,
totalPages: 0,
};
}
// 필터링
let filtered = allHistory;
// 상태 필터
if (status !== "all") {
filtered = filtered.filter((h) => h.status === status);
}
// 계정 필터
if (accountId) {
filtered = filtered.filter((h) => h.accountId === accountId);
}
// 날짜 필터
if (startDate) {
filtered = filtered.filter((h) => h.sentAt >= startDate);
}
if (endDate) {
filtered = filtered.filter((h) => h.sentAt <= endDate);
}
// 검색어 필터 (제목, 받는사람)
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(h) =>
h.subject.toLowerCase().includes(term) ||
h.to.some((email) => email.toLowerCase().includes(term)) ||
(h.cc && h.cc.some((email) => email.toLowerCase().includes(term)))
);
}
// 정렬
filtered.sort((a, b) => {
let aVal: any = a[sortBy];
let bVal: any = b[sortBy];
if (sortBy === "sentAt") {
aVal = new Date(aVal).getTime();
bVal = new Date(bVal).getTime();
} else {
aVal = aVal ? aVal.toLowerCase() : "";
bVal = bVal ? bVal.toLowerCase() : "";
}
if (sortOrder === "asc") {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
// 페이징
const total = filtered.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const end = start + limit;
const items = filtered.slice(start, end);
return {
items,
total,
page,
limit,
totalPages,
};
}
/**
*
*/
async getSentMailById(id: string): Promise<SentMailHistory | null> {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, "utf-8");
return JSON.parse(content) as SentMailHistory;
} catch (error) {
console.error("발송 이력 읽기 실패:", error);
return null;
}
}
/**
*
*/
async deleteSentMail(id: string): Promise<boolean> {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
console.log("🗑️ 발송 이력 삭제:", id);
return true;
} catch (error) {
console.error("발송 이력 삭제 실패:", error);
return false;
}
}
/**
*
*/
async getStatistics(accountId?: string): Promise<{
totalSent: number;
successCount: number;
failedCount: number;
todayCount: number;
thisMonthCount: number;
successRate: number;
}> {
let allHistory: SentMailHistory[] = [];
try {
// 디렉토리가 없으면 빈 통계 반환
if (!fs.existsSync(SENT_MAIL_DIR)) {
return {
totalSent: 0,
successCount: 0,
failedCount: 0,
todayCount: 0,
thisMonthCount: 0,
successRate: 0,
};
}
const files = fs
.readdirSync(SENT_MAIL_DIR)
.filter((f) => f.endsWith(".json"));
for (const file of files) {
try {
const filePath = path.join(SENT_MAIL_DIR, file);
const content = fs.readFileSync(filePath, "utf-8");
const history: SentMailHistory = JSON.parse(content);
// 계정 필터
if (!accountId || history.accountId === accountId) {
allHistory.push(history);
}
} catch (error) {
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
}
}
} catch (error) {
console.error("통계 조회 실패:", error);
return {
totalSent: 0,
successCount: 0,
failedCount: 0,
todayCount: 0,
thisMonthCount: 0,
successRate: 0,
};
}
const now = new Date();
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
).toISOString();
const monthStart = new Date(
now.getFullYear(),
now.getMonth(),
1
).toISOString();
const totalSent = allHistory.length;
const successCount = allHistory.filter(
(h) => h.status === "success"
).length;
const failedCount = allHistory.filter((h) => h.status === "failed").length;
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
const thisMonthCount = allHistory.filter(
(h) => h.sentAt >= monthStart
).length;
const successRate =
totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
return {
totalSent,
successCount,
failedCount,
todayCount,
thisMonthCount,
successRate,
};
}
}
export const mailSentHistoryService = new MailSentHistoryService();

View File

@ -1,5 +1,5 @@
import fs from 'fs/promises'; import fs from "fs/promises";
import path from 'path'; import path from "path";
// MailComponent 인터페이스 정의 // MailComponent 인터페이스 정의
export interface MailComponent { export interface MailComponent {
@ -30,7 +30,7 @@ export interface MailTemplate {
queries: QueryConfig[]; queries: QueryConfig[];
}; };
recipientConfig?: { recipientConfig?: {
type: 'query' | 'manual'; type: "query" | "manual";
emailField?: string; emailField?: string;
nameField?: string; nameField?: string;
queryId?: string; queryId?: string;
@ -45,19 +45,26 @@ class MailTemplateFileService {
private templatesDir: string; private templatesDir: string;
constructor() { constructor() {
// uploads/mail-templates 디렉토리 사용 // 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates'); this.templatesDir =
process.env.NODE_ENV === "production"
? "/app/uploads/mail-templates"
: path.join(process.cwd(), "uploads", "mail-templates");
this.ensureDirectoryExists(); this.ensureDirectoryExists();
} }
/** /**
* 릿 () * 릿 () - try-catch로
*/ */
private async ensureDirectoryExists() { private async ensureDirectoryExists() {
try { try {
await fs.access(this.templatesDir); await fs.access(this.templatesDir);
} catch { } catch {
await fs.mkdir(this.templatesDir, { recursive: true }); try {
await fs.mkdir(this.templatesDir, { recursive: true });
} catch (error) {
console.error("메일 템플릿 디렉토리 생성 실패:", error);
}
} }
} }
@ -73,24 +80,25 @@ class MailTemplateFileService {
*/ */
async getAllTemplates(): Promise<MailTemplate[]> { async getAllTemplates(): Promise<MailTemplate[]> {
await this.ensureDirectoryExists(); await this.ensureDirectoryExists();
try { try {
const files = await fs.readdir(this.templatesDir); const files = await fs.readdir(this.templatesDir);
const jsonFiles = files.filter(f => f.endsWith('.json')); const jsonFiles = files.filter((f) => f.endsWith(".json"));
const templates = await Promise.all( const templates = await Promise.all(
jsonFiles.map(async (file) => { jsonFiles.map(async (file) => {
const content = await fs.readFile( const content = await fs.readFile(
path.join(this.templatesDir, file), path.join(this.templatesDir, file),
'utf-8' "utf-8"
); );
return JSON.parse(content) as MailTemplate; return JSON.parse(content) as MailTemplate;
}) })
); );
// 최신순 정렬 // 최신순 정렬
return templates.sort((a, b) => return templates.sort(
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
} catch (error) { } catch (error) {
return []; return [];
@ -102,7 +110,7 @@ class MailTemplateFileService {
*/ */
async getTemplateById(id: string): Promise<MailTemplate | null> { async getTemplateById(id: string): Promise<MailTemplate | null> {
try { try {
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8'); const content = await fs.readFile(this.getTemplatePath(id), "utf-8");
return JSON.parse(content); return JSON.parse(content);
} catch { } catch {
return null; return null;
@ -113,7 +121,7 @@ class MailTemplateFileService {
* 릿 * 릿
*/ */
async createTemplate( async createTemplate(
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'> data: Omit<MailTemplate, "id" | "createdAt" | "updatedAt">
): Promise<MailTemplate> { ): Promise<MailTemplate> {
const id = `template-${Date.now()}`; const id = `template-${Date.now()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -128,7 +136,7 @@ class MailTemplateFileService {
await fs.writeFile( await fs.writeFile(
this.getTemplatePath(id), this.getTemplatePath(id),
JSON.stringify(template, null, 2), JSON.stringify(template, null, 2),
'utf-8' "utf-8"
); );
return template; return template;
@ -139,28 +147,37 @@ class MailTemplateFileService {
*/ */
async updateTemplate( async updateTemplate(
id: string, id: string,
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>> data: Partial<Omit<MailTemplate, "id" | "createdAt">>
): Promise<MailTemplate | null> { ): Promise<MailTemplate | null> {
const existing = await this.getTemplateById(id); try {
if (!existing) { const existing = await this.getTemplateById(id);
return null; if (!existing) {
// console.error(`❌ 템플릿을 찾을 수 없음: ${id}`);
return null;
}
const updated: MailTemplate = {
...existing,
...data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
await fs.writeFile(
this.getTemplatePath(id),
JSON.stringify(updated, null, 2),
"utf-8"
);
// console.log(`✅ 템플릿 저장 성공: ${id}`);
return updated;
} catch (error) {
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
throw error; // 에러를 컨트롤러로 전달
} }
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;
} }
/** /**
@ -179,40 +196,41 @@ class MailTemplateFileService {
* 릿 HTML로 * 릿 HTML로
*/ */
renderTemplateToHtml(components: MailComponent[]): string { renderTemplateToHtml(components: MailComponent[]): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">'; let html =
'<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
components.forEach(comp => { components.forEach((comp) => {
const styles = Object.entries(comp.styles || {}) const styles = Object.entries(comp.styles || {})
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`) .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
.join('; '); .join("; ");
switch (comp.type) { switch (comp.type) {
case 'text': case "text":
html += `<div style="${styles}">${comp.content || ''}</div>`; html += `<div style="${styles}">${comp.content || ""}</div>`;
break; break;
case 'button': case "button":
html += `<div style="text-align: center; ${styles}"> html += `<div style="text-align: center; ${styles}">
<a href="${comp.url || '#'}" <a href="${comp.url || "#"}"
style="display: inline-block; padding: 12px 24px; text-decoration: none; style="display: inline-block; padding: 12px 24px; text-decoration: none;
background-color: ${comp.styles?.backgroundColor || '#007bff'}; background-color: ${comp.styles?.backgroundColor || "#007bff"};
color: ${comp.styles?.color || '#fff'}; color: ${comp.styles?.color || "#fff"};
border-radius: 4px;"> border-radius: 4px;">
${comp.text || 'Button'} ${comp.text || "Button"}
</a> </a>
</div>`; </div>`;
break; break;
case 'image': case "image":
html += `<div style="${styles}"> html += `<div style="${styles}">
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" /> <img src="${comp.src || ""}" alt="" style="max-width: 100%; height: auto;" />
</div>`; </div>`;
break; break;
case 'spacer': case "spacer":
html += `<div style="height: ${comp.height || 20}px;"></div>`; html += `<div style="height: ${comp.height || 20}px;"></div>`;
break; break;
} }
}); });
html += '</div>'; html += "</div>";
return html; return html;
} }
@ -220,7 +238,7 @@ class MailTemplateFileService {
* camelCase를 kebab-case로 * camelCase를 kebab-case로
*/ */
private camelToKebab(str: string): string { private camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
} }
/** /**
@ -228,7 +246,7 @@ class MailTemplateFileService {
*/ */
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> { async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates(); const allTemplates = await this.getAllTemplates();
return allTemplates.filter(t => t.category === category); return allTemplates.filter((t) => t.category === category);
} }
/** /**
@ -237,14 +255,14 @@ class MailTemplateFileService {
async searchTemplates(keyword: string): Promise<MailTemplate[]> { async searchTemplates(keyword: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates(); const allTemplates = await this.getAllTemplates();
const lowerKeyword = keyword.toLowerCase(); const lowerKeyword = keyword.toLowerCase();
return allTemplates.filter(t => return allTemplates.filter(
t.name.toLowerCase().includes(lowerKeyword) || (t) =>
t.subject.toLowerCase().includes(lowerKeyword) || t.name.toLowerCase().includes(lowerKeyword) ||
t.category?.toLowerCase().includes(lowerKeyword) t.subject.toLowerCase().includes(lowerKeyword) ||
t.category?.toLowerCase().includes(lowerKeyword)
); );
} }
} }
export const mailTemplateFileService = new MailTemplateFileService(); export const mailTemplateFileService = new MailTemplateFileService();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
/**
*
*/
export interface SentMailHistory {
id: string; // UUID
accountId: string; // 발송 계정 ID
accountName: string; // 발송 계정 이름
accountEmail: string; // 발송 계정 이메일
// 수신자 정보
to: string[]; // 받는 사람
cc?: string[]; // 참조
bcc?: string[]; // 숨은참조
// 메일 내용
subject: string; // 제목
htmlContent: string; // HTML 내용
templateId?: string; // 사용한 템플릿 ID (있는 경우)
templateName?: string; // 사용한 템플릿 이름
// 첨부파일 정보
attachments?: AttachmentInfo[];
// 발송 정보
sentAt: string; // 발송 시간 (ISO 8601)
status: 'success' | 'failed'; // 발송 상태
messageId?: string; // SMTP 메시지 ID (성공 시)
errorMessage?: string; // 오류 메시지 (실패 시)
// 발송 결과
accepted?: string[]; // 수락된 이메일 주소
rejected?: string[]; // 거부된 이메일 주소
}
export interface AttachmentInfo {
filename: string; // 저장된 파일명
originalName: string; // 원본 파일명
size: number; // 파일 크기 (bytes)
path: string; // 파일 경로
mimetype: string; // MIME 타입
}
export interface SentMailListQuery {
page?: number; // 페이지 번호 (1부터 시작)
limit?: number; // 페이지당 항목 수
searchTerm?: string; // 검색어 (제목, 받는사람)
status?: 'success' | 'failed' | 'all'; // 필터: 상태
accountId?: string; // 필터: 발송 계정
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
sortBy?: 'sentAt' | 'subject'; // 정렬 기준
sortOrder?: 'asc' | 'desc'; // 정렬 순서
}
export interface SentMailListResponse {
items: SentMailHistory[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,152 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
template_type: string;
is_system: string;
thumbnail_url: string | null;
description: string | null;
layout_config: string | null;
default_queries: string | null;
use_yn: string;
sort_order: number;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
report_type: string;
company_code: string | null;
description: string | null;
use_yn: string;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
canvas_width: number;
canvas_height: number;
page_orientation: string;
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
components: string | null;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 쿼리
export interface ReportQuery {
query_id: string;
report_id: string;
query_name: string;
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[] | null;
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
display_order: number;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: any[];
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
}>;
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: any;
defaultQueries?: any;
}

View File

@ -34,8 +34,14 @@ COPY --from=build /app/dist ./dist
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Create logs and uploads directories and set permissions (use existing node user with UID 1000) # Create logs, uploads, and data 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 RUN mkdir -p logs \
uploads/mail-attachments \
uploads/mail-templates \
uploads/mail-accounts \
data/mail-sent && \
chown -R node:node logs uploads data && \
chmod -R 755 logs uploads data
EXPOSE 3001 EXPOSE 3001
USER node USER node

View File

@ -20,7 +20,8 @@ services:
LOG_LEVEL: info LOG_LEVEL: info
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
volumes: volumes:
- /home/vexplor/backend_data:/app/uploads - /home/vexplor/backend_data/uploads:/app/uploads
- /home/vexplor/backend_data/data:/app/data
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`) - traefik.http.routers.backend.rule=Host(`api.vexplor.com`)

View File

@ -0,0 +1,591 @@
# 리포트 디자이너 그리드 시스템 구현 계획
## 개요
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
## 목표
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
## 핵심 개념
### 그리드 시스템
```typescript
interface GridConfig {
// 그리드 설정
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
// 표시 설정
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
// 시각적 설정
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
```
### 컴포넌트 위치/크기 (그리드 기반)
```typescript
interface ComponentPosition {
// 그리드 좌표 (셀 단위)
gridX: number; // 시작 열 (0부터 시작)
gridY: number; // 시작 행 (0부터 시작)
gridWidth: number; // 차지하는 열 수
gridHeight: number; // 차지하는 행 수
// 실제 픽셀 좌표 (계산값)
x: number; // gridX * cellWidth
y: number; // gridY * cellHeight
width: number; // gridWidth * cellWidth
height: number; // gridHeight * cellHeight
}
```
## 구현 단계
### Phase 1: 그리드 시스템 기반 구조
#### 1.1 타입 정의
- **파일**: `frontend/types/report.ts`
- **내용**:
- `GridConfig` 인터페이스 추가
- `ComponentConfig``gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
- `ReportPage``gridConfig` 추가
#### 1.2 Context 확장
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
- **내용**:
- `gridConfig` 상태 추가
- `updateGridConfig()` 함수 추가
- `snapToGrid()` 유틸리티 함수 추가
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
#### 1.3 그리드 계산 유틸리티
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
- **내용**:
```typescript
// 픽셀 좌표 → 그리드 좌표 변환
export function pixelToGrid(pixel: number, cellSize: number): number;
// 그리드 좌표 → 픽셀 좌표 변환
export function gridToPixel(grid: number, cellSize: number): number;
// 컴포넌트 위치/크기를 그리드에 스냅
export function snapComponentToGrid(
component: ComponentConfig,
gridConfig: GridConfig
): ComponentConfig;
// 그리드 충돌 감지
export function detectGridCollision(
component: ComponentConfig,
otherComponents: ComponentConfig[]
): boolean;
```
### Phase 2: 그리드 시각화
#### 2.1 그리드 레이어 컴포넌트
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
- **내용**:
- Canvas 위에 그리드 선 렌더링
- SVG 또는 Canvas API 사용
- 그리드 크기/색상/투명도 적용
- 줌/스크롤 시에도 정확한 위치 유지
```tsx
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({
gridConfig,
pageWidth,
pageHeight,
}: GridLayerProps) {
if (!gridConfig.visible) return null;
// SVG로 그리드 선 렌더링
return (
<svg className="absolute inset-0 pointer-events-none">
{/* 세로 선 */}
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * gridConfig.cellWidth}
y1={0}
x2={i * gridConfig.cellWidth}
y2={pageHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
{/* 가로 선 */}
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * gridConfig.cellHeight}
x2={pageWidth}
y2={i * gridConfig.cellHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
</svg>
);
}
```
#### 2.2 Canvas 통합
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `<GridLayer />` 추가
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
### Phase 3: 드래그 앤 드롭 스냅
#### 3.1 드래그 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `useDrop` 훅 수정
- 드롭 위치를 그리드에 스냅
- 실시간 스냅 가이드 표시
```typescript
const [, drop] = useDrop({
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
drop: (item: any, monitor) => {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 상대 좌표 계산
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
let x = offset.x - canvasRect.left;
let y = offset.y - canvasRect.top;
// 그리드 스냅 적용
if (gridConfig.snapToGrid) {
const gridX = Math.round(x / gridConfig.cellWidth);
const gridY = Math.round(y / gridConfig.cellHeight);
x = gridX * gridConfig.cellWidth;
y = gridY * gridConfig.cellHeight;
}
// 컴포넌트 추가
addComponent({ type: item.type, x, y });
},
});
```
#### 3.2 리사이즈 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
- **내용**:
- `react-resizable` 또는 `react-rnd``snap` 설정 활용
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
```typescript
<Rnd
position={{ x: component.x, y: component.y }}
size={{ width: component.width, height: component.height }}
onDragStop={(e, d) => {
let newX = d.x;
let newY = d.y;
if (gridConfig.snapToGrid) {
const gridX = Math.round(newX / gridConfig.cellWidth);
const gridY = Math.round(newY / gridConfig.cellHeight);
newX = gridX * gridConfig.cellWidth;
newY = gridY * gridConfig.cellHeight;
}
updateComponent(component.id, { x: newX, y: newY });
}}
onResizeStop={(e, direction, ref, delta, position) => {
let newWidth = parseInt(ref.style.width);
let newHeight = parseInt(ref.style.height);
if (gridConfig.snapToGrid) {
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
newWidth = gridWidth * gridConfig.cellWidth;
newHeight = gridHeight * gridConfig.cellHeight;
}
updateComponent(component.id, {
width: newWidth,
height: newHeight,
...position,
});
}}
grid={
gridConfig.snapToGrid
? [gridConfig.cellWidth, gridConfig.cellHeight]
: undefined
}
/>
```
### Phase 4: 그리드 설정 UI
#### 4.1 그리드 설정 패널
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
- **내용**:
- 그리드 크기 조절 (cellWidth, cellHeight)
- 그리드 표시/숨김 토글
- 스냅 활성화/비활성화 토글
- 그리드 색상/투명도 조절
```tsx
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">그리드 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label>그리드 표시</Label>
<Switch
checked={gridConfig.visible}
onCheckedChange={(visible) => updateGridConfig({ visible })}
/>
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label>그리드 스냅</Label>
<Switch
checked={gridConfig.snapToGrid}
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
/>
</div>
{/* 셀 크기 */}
<div className="space-y-2">
<Label>셀 너비 (px)</Label>
<Input
type="number"
value={gridConfig.cellWidth}
onChange={(e) =>
updateGridConfig({ cellWidth: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
<div className="space-y-2">
<Label>셀 높이 (px)</Label>
<Input
type="number"
value={gridConfig.cellHeight}
onChange={(e) =>
updateGridConfig({ cellHeight: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label>프리셋</Label>
<Select
onValueChange={(value) => {
const presets: Record<
string,
{ cellWidth: number; cellHeight: number }
> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger>
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine">세밀 (10x10)</SelectItem>
<SelectItem value="medium">중간 (20x20)</SelectItem>
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}
```
#### 4.2 툴바에 그리드 토글 추가
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
- **내용**:
- 그리드 표시/숨김 버튼
- 그리드 설정 모달 열기 버튼
- 키보드 단축키 (`G` 키로 그리드 토글)
### Phase 5: Word 변환 개선
#### 5.1 그리드 기반 레이아웃 변환
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
- **내용**:
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
- 그리드 행/열을 Word 테이블의 행/열로 매핑
```typescript
const handleDownloadWord = async () => {
// 그리드 기반으로 컴포넌트 배치 맵 생성
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
.fill(null)
.map(() => Array(gridConfig.columns).fill(null));
// 각 컴포넌트를 그리드 맵에 배치
for (const component of components) {
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
// 컴포넌트가 차지하는 모든 셀에 참조 저장
for (let y = gridY; y < gridY + gridHeight; y++) {
for (let x = gridX; x < gridX + gridWidth; x++) {
if (y < gridConfig.rows && x < gridConfig.columns) {
gridMap[y][x] = component;
}
}
}
}
// 그리드 맵을 Word 테이블로 변환
const tableRows: TableRow[] = [];
for (let y = 0; y < gridConfig.rows; y++) {
const cells: TableCell[] = [];
let x = 0;
while (x < gridConfig.columns) {
const component = gridMap[y][x];
if (!component) {
// 빈 셀
cells.push(new TableCell({ children: [new Paragraph("")] }));
x++;
} else {
// 컴포넌트 셀
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
const cell = createTableCell(component, gridWidth, gridHeight);
if (cell) cells.push(cell);
x += gridWidth;
}
}
if (cells.length > 0) {
tableRows.push(new TableRow({ children: cells }));
}
}
// ... Word 문서 생성
};
```
### Phase 6: 데이터 마이그레이션
#### 6.1 기존 레이아웃 자동 변환
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
- **내용**:
- 기존 절대 위치 데이터를 그리드 기반으로 변환
- 가장 가까운 그리드 셀에 스냅
- 마이그레이션 로그 생성
```typescript
export function migrateLayoutToGrid(
layout: ReportLayoutConfig,
gridConfig: GridConfig
): ReportLayoutConfig {
return {
...layout,
pages: layout.pages.map((page) => ({
...page,
gridConfig,
components: page.components.map((component) => {
// 픽셀 좌표를 그리드 좌표로 변환
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.max(
1,
Math.round(component.width / gridConfig.cellWidth)
);
const gridHeight = Math.max(
1,
Math.round(component.height / gridConfig.cellHeight)
);
return {
...component,
gridX,
gridY,
gridWidth,
gridHeight,
x: gridX * gridConfig.cellWidth,
y: gridY * gridConfig.cellHeight,
width: gridWidth * gridConfig.cellWidth,
height: gridHeight * gridConfig.cellHeight,
};
}),
})),
};
}
```
#### 6.2 마이그레이션 UI
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
- **내용**:
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
- 마이그레이션 전/후 미리보기
- 사용자 확인 후 적용
## 데이터베이스 스키마 변경
### report_layout_pages 테이블
```sql
ALTER TABLE report_layout_pages
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
```
### report_layout_components 테이블
```sql
ALTER TABLE report_layout_components
ADD COLUMN grid_x INTEGER,
ADD COLUMN grid_y INTEGER,
ADD COLUMN grid_width INTEGER,
ADD COLUMN grid_height INTEGER;
-- 기존 데이터 마이그레이션
UPDATE report_layout_components
SET
grid_x = ROUND(position_x / 20.0),
grid_y = ROUND(position_y / 20.0),
grid_width = GREATEST(1, ROUND(width / 20.0)),
grid_height = GREATEST(1, ROUND(height / 20.0))
WHERE grid_x IS NULL;
```
## 테스트 계획
### 단위 테스트
- `gridUtils.ts`의 모든 함수 테스트
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
- 충돌 감지 로직
### 통합 테스트
- 드래그 앤 드롭 시 그리드 스냅 동작
- 리사이즈 시 그리드 스냅 동작
- 그리드 크기 변경 시 컴포넌트 재배치
### E2E 테스트
- 새 리포트 생성 및 그리드 설정
- 기존 리포트 마이그레이션
- Word 다운로드 시 레이아웃 정확성
## 예상 개발 일정
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
- **Phase 2**: 그리드 시각화 (1일)
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
- **Phase 4**: 그리드 설정 UI (1일)
- **Phase 5**: Word 변환 개선 (2일)
- **Phase 6**: 데이터 마이그레이션 (1일)
- **테스트 및 디버깅**: (2일)
**총 예상 기간**: 11일
## 기술적 고려사항
### 성능 최적화
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
- 메모이제이션: 그리드 계산 결과 캐싱
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
### 사용자 경험
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
### 하위 호환성
- 기존 리포트는 자동 마이그레이션 제공
- 마이그레이션 옵션: 자동 / 수동 선택 가능
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
## 추가 기능 (향후 확장)
### 스마트 가이드
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
- 균등 간격 가이드
### 그리드 템플릿
- 자주 사용하는 그리드 레이아웃 템플릿 제공
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
### 그리드 병합
- 여러 그리드 셀을 하나로 병합
- 복잡한 레이아웃 지원
## 참고 자료
- Android Home Screen Widget System
- Microsoft Word Table Layout
- CSS Grid Layout
- Figma Auto Layout

View File

@ -0,0 +1,358 @@
# 리포트 관리 시스템 구현 진행 상황
## 프로젝트 개요
동적 리포트 디자이너 시스템 구현
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
- SQL 쿼리 연동으로 실시간 데이터 표시
- 미리보기 및 인쇄 기능
---
## 완료된 작업 ✅
### 1. 데이터베이스 설계 및 구축
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
- [x] `report_query` 테이블 생성 (쿼리 정의)
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
### 2. 백엔드 API 구현
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
- [x] 템플릿 조회 API
- [x] 레이아웃 저장/조회 API
- [x] 쿼리 실행 API (파라미터 지원)
- [x] 리포트 복사 API
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
**파일**:
- `backend-node/src/types/report.ts`
- `backend-node/src/services/reportService.ts`
- `backend-node/src/controllers/reportController.ts`
- `backend-node/src/routes/reportRoutes.ts`
### 3. 프론트엔드 - 리포트 목록 페이지
- [x] 리포트 리스트 조회 및 표시
- [x] 검색 기능
- [x] 페이지네이션
- [x] 새 리포트 생성 (디자이너로 이동)
- [x] 수정/복사/삭제 액션 버튼
**파일**:
- `frontend/app/(main)/admin/report/page.tsx`
- `frontend/components/report/ReportListTable.tsx`
- `frontend/hooks/useReportList.ts`
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
- [x] "new" 리포트 처리 (저장 시 생성)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx`
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
### 5. 컴포넌트 팔레트 및 캔버스
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
- [x] 컴포넌트 이동 (드래그)
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
- [x] 컴포넌트 선택 및 삭제
**파일**:
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 6. 쿼리 관리 시스템
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
- [x] 파라미터 타입 선택 (text, number, date)
- [x] 파라미터 입력값 검증
- [x] 쿼리 실행 및 결과 표시
- [x] "new" 리포트에서도 쿼리 실행 가능
- [x] 실행 결과를 Context에 저장
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
### 7. 데이터 바인딩 시스템
- [x] 속성 패널에서 컴포넌트-쿼리 연결
- [x] 텍스트/레이블: 쿼리 + 필드 선택
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
- [x] 실행 결과가 없으면 `{필드명}` 표시
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 8. 미리보기 및 내보내기
- [x] 미리보기 모달
- [x] 실제 쿼리 데이터로 렌더링
- [x] 편집용 UI 제거 (순수 데이터만 표시)
- [x] 브라우저 인쇄 기능
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
- [x] WORD 다운로드 (docx 라이브러리)
- [x] 파일명 자동 생성 (리포트명\_날짜)
**파일**:
- `frontend/components/report/designer/ReportPreviewModal.tsx`
**사용 라이브러리**:
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
### 9. 템플릿 시스템
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
- [x] 템플릿별 기본 컴포넌트 자동 배치
- [x] 템플릿별 기본 쿼리 자동 생성
- [x] 사용자 정의 템플릿 저장 기능
- [x] 사용자 정의 템플릿 목록 조회
- [x] 사용자 정의 템플릿 삭제
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
- `frontend/components/report/designer/TemplatePalette.tsx`
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
### 10. 외부 DB 연동
- [x] 쿼리별 외부 DB 연결 선택
- [x] 외부 DB 연결 목록 조회 API
- [x] 쿼리 실행 시 외부 DB 지원
- [x] 내부/외부 DB 선택 UI
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
### 11. 컴포넌트 스타일링
- [x] 폰트 크기 설정
- [x] 폰트 색상 설정 (컬러피커)
- [x] 폰트 굵기 (보통/굵게)
- [x] 텍스트 정렬 (좌/중/우)
- [x] 배경색 설정 (투명 옵션 포함)
- [x] 테두리 설정 (두께, 색상)
- [x] 캔버스 및 미리보기에 스타일 반영
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 12. 레이아웃 도구 (완료!)
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
---
## 진행 중인 작업 🚧
없음 (모든 레이아웃 도구 구현 완료!)
---
## 남은 작업 (우선순위순) 📋
### Phase 1: 추가 컴포넌트 ✅ 완료!
1. **이미지 컴포넌트**
- [x] 파일 업로드 (multer, 10MB 제한)
- [x] 회사별 디렉토리 분리 저장
- [x] 맞춤 방식 (contain/cover/fill/none)
- [x] CORS 설정으로 이미지 로딩
- [x] 캔버스 및 미리보기 렌더링
- 로고, 서명, 도장 등에 활용
2. **구분선 컴포넌트 (Divider)**
- [x] 가로/세로 방향 선택
- [x] 선 두께 (lineWidth) 독립 속성
- [x] 선 색상 (lineColor) 독립 속성
- [x] 선 스타일 (solid/dashed/dotted/double)
- [x] 캔버스 및 미리보기 렌더링
**파일**:
- `backend-node/src/controllers/reportController.ts` (uploadImage)
- `backend-node/src/routes/reportRoutes.ts` (multer 설정)
- `frontend/types/report.ts` (이미지/구분선 속성)
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/ReportPreviewModal.tsx`
- `frontend/lib/api/client.ts` (getFullImageUrl)
3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
- 막대 차트
- 선 차트
- 원형 차트
- 쿼리 데이터 연동
### Phase 2: 고급 기능
4. **조건부 서식**
- 특정 조건에 따른 스타일 변경
- 값 범위에 따른 색상 표시
- 수식 기반 표시/숨김
5. **쿼리 관리 개선**
- 쿼리 미리보기 개선 (테이블 형태)
- 쿼리 저장/불러오기
- 쿼리 템플릿
### Phase 3: 성능 및 보안
6. **성능 최적화**
- 쿼리 결과 캐싱
- 대용량 데이터 페이징
- 렌더링 최적화
- 이미지 레이지 로딩
7. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
- 사용자별 리포트 목록 필터링
---
## 기술 스택
### 백엔드
- Node.js + TypeScript
- Express.js
- PostgreSQL (raw SQL)
- pg (node-postgres)
### 프론트엔드
- Next.js 14 (App Router)
- React 18
- TypeScript
- Tailwind CSS
- Shadcn UI
- react-dnd (드래그 앤 드롭)
---
## 주요 아키텍처 결정
### 1. Context API 사용
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
- 컴포넌트 간 prop drilling 방지
### 2. Raw SQL 사용
- Prisma 대신 직접 SQL 작성
- 복잡한 쿼리와 트랜잭션 처리에 유리
- 데이터베이스 제어 수준 향상
### 3. JSON 기반 레이아웃 저장
- 레이아웃을 JSONB로 DB에 저장
- 버전 관리 용이
- 유연한 스키마
### 4. 쿼리 실행 결과 메모리 관리
- Context에 쿼리 결과 저장
- 컴포넌트에서 실시간 참조
- 불필요한 API 호출 방지
---
## 참고 문서
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
---
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
### 테스트 항목
1. **복사 기능 테스트**
- 리포트 복사 버튼 클릭
- 복사된 리포트명 확인 (원본명 + "\_copy")
- 복사된 리포트의 레이아웃 확인
- 복사된 리포트의 쿼리 확인
- 목록 자동 새로고침 확인
2. **삭제 기능 테스트**
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
- 취소 버튼 동작 확인
- 삭제 실행 후 목록에서 제거 확인
- Toast 메시지 표시 확인
3. **에러 처리 테스트**
- 존재하지 않는 리포트 삭제 시도
- 네트워크 오류 시 Toast 메시지
- 로딩 중 버튼 비활성화 확인
### 추가 개선 사항
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
- [ ] 다중 선택 및 정렬 기능
- [ ] 실행 취소/다시 실행 (Undo/Redo)
- [ ] 사용자 정의 템플릿 저장
---
**최종 업데이트**: 2025-10-01
**작성자**: AI Assistant
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)

View File

@ -0,0 +1,679 @@
# 리포트 관리 시스템 설계
## 1. 프로젝트 개요
### 1.1 목적
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
### 1.2 주요 기능
- 리포트 목록 조회 및 관리
- 드래그 앤 드롭 기반 리포트 디자이너
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
- 쿼리 관리 (마스터/디테일)
- 외부 DB 연동
- 인쇄 및 내보내기 (PDF, WORD)
- 미리보기 기능
## 2. 화면 구성
### 2.1 리포트 목록 화면 (`/admin/report`)
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 관리 [+ 새 리포트] │
├──────────────────────────────────────────────────────────────────┤
│ 검색: [____________________] [검색] [초기화] │
├──────────────────────────────────────────────────────────────────┤
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
├────┼──────────────┼────────┼───────────┼────────────────────────┤
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
└──────────────────────────────────────────────────────────────────┘
```
**기능**
- 리포트 목록 조회 (페이징, 정렬, 검색)
- 새 리포트 생성
- 기존 리포트 수정
- 리포트 복사
- 리포트 삭제
- 리포트 미리보기
### 2.2 리포트 디자이너 화면
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
├──────┬────────────────────────────────────────────────┬──────────┤
│ │ │ │
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
│ │ │ │
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
│ │ │ │
│ │ │ DB 연동 │
└──────┴────────────────────────────────────────────────┴──────────┘
```
### 2.3 미리보기 모달
```
┌──────────────────────────────────────────────────────────────────┐
│ 미리보기 [닫기] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ [리포트 내용 미리보기] │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [인쇄] [PDF] [WORD] │
└──────────────────────────────────────────────────────────────────┘
```
## 3. 데이터베이스 설계
### 3.1 테이블 구조
#### REPORT_TEMPLATE (리포트 템플릿)
```sql
CREATE TABLE report_template (
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
description TEXT, -- 템플릿 설명
layout_config TEXT, -- 레이아웃 설정 (JSON)
default_queries TEXT, -- 기본 쿼리 (JSON)
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
sort_order INTEGER DEFAULT 0, -- 정렬 순서
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50)
);
```
#### REPORT_MASTER (리포트 마스터)
```sql
CREATE TABLE report_master (
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
report_name_eng VARCHAR(100), -- 리포트명 (영어)
template_id VARCHAR(50), -- 템플릿 ID (FK)
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
company_code VARCHAR(20), -- 회사 코드
description TEXT, -- 설명
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
);
```
#### REPORT_LAYOUT (리포트 레이아웃)
```sql
CREATE TABLE report_layout (
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
components TEXT, -- 컴포넌트 배치 정보 (JSON)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
);
```
## 4. 컴포넌트 목록
### 4.1 기본 컴포넌트
#### 텍스트 관련
- **Text Field**: 단일 라인 텍스트 입력/표시
- **Text Area**: 여러 줄 텍스트 입력/표시
- **Label**: 고정 라벨 텍스트
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
#### 숫자/날짜 관련
- **Number**: 숫자 표시 (통화 형식 지원)
- **Date**: 날짜 표시 (형식 지정 가능)
- **Date Time**: 날짜 + 시간 표시
- **Calculate Field**: 계산 필드 (합계, 평균 등)
#### 테이블/그리드
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
- **Summary Table**: 요약 테이블
- **Group Table**: 그룹핑 테이블
#### 이미지/그래픽
- **Image**: 이미지 표시 (로고, 서명 등)
- **Line**: 구분선
- **Rectangle**: 사각형 (테두리)
#### 특수 컴포넌트
- **Page Number**: 페이지 번호
- **Current Date**: 현재 날짜/시간
- **Company Info**: 회사 정보 (자동)
- **Signature**: 서명란
- **Stamp**: 도장란
### 4.2 컴포넌트 속성
각 컴포넌트는 다음 공통 속성을 가집니다:
```typescript
interface ComponentBase {
id: string; // 컴포넌트 ID
type: string; // 컴포넌트 타입
x: number; // X 좌표
y: number; // Y 좌표
width: number; // 너비
height: number; // 높이
zIndex: number; // Z-인덱스
// 스타일
fontSize?: number; // 글자 크기
fontFamily?: string; // 폰트
fontWeight?: string; // 글자 굵기
fontColor?: string; // 글자 색상
backgroundColor?: string; // 배경색
borderWidth?: number; // 테두리 두께
borderColor?: string; // 테두리 색상
borderRadius?: number; // 모서리 둥글기
textAlign?: string; // 텍스트 정렬
padding?: number; // 내부 여백
// 데이터 바인딩
queryId?: string; // 연결된 쿼리 ID
fieldName?: string; // 필드명
defaultValue?: string; // 기본값
format?: string; // 표시 형식
// 기타
visible?: boolean; // 표시 여부
printable?: boolean; // 인쇄 여부
conditional?: string; // 조건부 표시 (수식)
}
```
## 5. 템플릿 목록
### 5.1 기본 템플릿 (시스템)
#### 구매/발주 관련
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
#### 판매/청구 관련
- **청구서 (Invoice)**: 고객에게 청구하는 문서
- **견적서 (Quotation)**: 견적 제공 문서
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
- **세금계산서 (Tax Invoice)**: 세금 계산서
- **영수증 (Receipt)**: 영수 증빙 문서
#### 재고/입출고 관련
- **입고증 (Goods Receipt)**: 입고 증빙 문서
- **출고증 (Delivery Note)**: 출고 증빙 문서
- **재고 현황표 (Inventory Report)**: 재고 현황
- **이동 전표 (Transfer Note)**: 재고 이동 문서
#### 생산 관련
- **작업지시서 (Work Order)**: 생산 작업 지시
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
- **불량 보고서 (Defect Report)**: 불량 보고
#### 회계/경영 관련
- **손익 계산서 (Income Statement)**: 손익 현황
- **대차대조표 (Balance Sheet)**: 재무 상태
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
- **급여 명세서 (Payroll Slip)**: 급여 내역
#### 일반 문서
- **기본 양식 (Basic Template)**: 빈 캔버스
- **일반 보고서 (General Report)**: 일반 보고 양식
- **목록 양식 (List Template)**: 목록형 양식
### 5.2 사용자 정의 템플릿
- 사용자가 직접 생성한 템플릿
- 기본 템플릿을 복사하여 수정 가능
- 회사별로 관리 가능
## 6. API 설계
### 6.1 리포트 목록 API
#### GET `/api/admin/reports`
리포트 목록 조회
```typescript
// Request
interface GetReportsRequest {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// Response
interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
```
#### GET `/api/admin/reports/:reportId`
리포트 상세 조회
```typescript
// Response
interface ReportDetail {
report: ReportMaster;
layout: ReportLayout;
queries: ReportQuery[];
components: Component[];
}
```
#### POST `/api/admin/reports`
리포트 생성
```typescript
// Request
interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
}
// Response
interface CreateReportResponse {
reportId: string;
message: string;
}
```
#### PUT `/api/admin/reports/:reportId`
리포트 수정
```typescript
// Request
interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
```
#### DELETE `/api/admin/reports/:reportId`
리포트 삭제
#### POST `/api/admin/reports/:reportId/copy`
리포트 복사
### 6.2 템플릿 API
#### GET `/api/admin/reports/templates`
템플릿 목록 조회
```typescript
// Response
interface GetTemplatesResponse {
system: ReportTemplate[]; // 시스템 템플릿
custom: ReportTemplate[]; // 사용자 정의 템플릿
}
```
#### POST `/api/admin/reports/templates`
템플릿 생성 (사용자 정의)
```typescript
// Request
interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig: any;
defaultQueries?: any;
}
```
#### PUT `/api/admin/reports/templates/:templateId`
템플릿 수정
#### DELETE `/api/admin/reports/templates/:templateId`
템플릿 삭제
### 6.3 레이아웃 API
#### GET `/api/admin/reports/:reportId/layout`
레이아웃 조회
#### PUT `/api/admin/reports/:reportId/layout`
레이아웃 저장
```typescript
// Request
interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: Component[];
}
```
### 6.4 인쇄/내보내기 API
#### POST `/api/admin/reports/:reportId/preview`
미리보기 생성
```typescript
// Request
interface PreviewRequest {
parameters?: { [key: string]: any };
format?: "HTML" | "PDF";
}
// Response
interface PreviewResponse {
html?: string; // HTML 미리보기
pdfUrl?: string; // PDF URL
}
```
#### POST `/api/admin/reports/:reportId/print`
인쇄 (PDF 생성)
```typescript
// Request
interface PrintRequest {
parameters?: { [key: string]: any };
format: "PDF" | "WORD" | "EXCEL";
}
// Response
interface PrintResponse {
fileUrl: string;
fileName: string;
fileSize: number;
}
```
## 7. 프론트엔드 구조
### 7.1 페이지 구조
```
/admin/report
├── ReportListPage.tsx # 리포트 목록 페이지
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
└── components/
├── ReportList.tsx # 리포트 목록 테이블
├── ReportSearchForm.tsx # 검색 폼
├── TemplateSelector.tsx # 템플릿 선택기
├── ComponentPalette.tsx # 컴포넌트 팔레트
├── Canvas.tsx # 캔버스 영역
├── ComponentRenderer.tsx # 컴포넌트 렌더러
├── PropertyPanel.tsx # 속성 패널
├── QueryManager.tsx # 쿼리 관리
├── QueryCard.tsx # 쿼리 카드
├── ConnectionManager.tsx # 외부 DB 연결 관리
├── PreviewModal.tsx # 미리보기 모달
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
```
### 7.2 상태 관리
```typescript
interface ReportDesignerState {
// 리포트 기본 정보
report: ReportMaster | null;
// 레이아웃
layout: ReportLayout | null;
components: Component[];
selectedComponentId: string | null;
// 쿼리
queries: ReportQuery[];
queryResults: { [queryId: string]: any[] };
// 외부 연결
connections: ReportExternalConnection[];
// UI 상태
isDragging: boolean;
isResizing: boolean;
showPreview: boolean;
showPrintOptions: boolean;
// 히스토리 (Undo/Redo)
history: {
past: Component[][];
present: Component[];
future: Component[][];
};
}
```
## 8. 구현 우선순위
### Phase 1: 기본 기능 (2주)
- [ ] 데이터베이스 테이블 생성
- [ ] 리포트 목록 화면
- [ ] 리포트 CRUD API
- [ ] 템플릿 목록 조회
- [ ] 기본 템플릿 데이터 생성
### Phase 2: 디자이너 기본 (2주)
- [ ] 캔버스 구현
- [ ] 컴포넌트 드래그 앤 드롭
- [ ] 컴포넌트 선택/이동/크기 조절
- [ ] 속성 패널 (기본)
- [ ] 저장/불러오기
### Phase 3: 쿼리 관리 (1주)
- [ ] 쿼리 추가/수정/삭제
- [ ] 파라미터 감지 및 입력
- [ ] 쿼리 실행 (내부 DB)
- [ ] 쿼리 결과를 컴포넌트에 바인딩
### Phase 4: 쿼리 관리 고급 (1주)
- [ ] 쿼리 필드 매핑
- [ ] 컴포넌트와 데이터 바인딩
- [ ] 파라미터 전달 및 처리
### Phase 5: 미리보기/인쇄 (1주)
- [ ] HTML 미리보기
- [ ] PDF 생성
- [ ] WORD 생성
- [ ] 브라우저 인쇄
### Phase 6: 고급 기능 (2주)
- [ ] 템플릿 생성 기능
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
- [ ] 계산 필드
- [ ] 조건부 표시
- [ ] Undo/Redo
- [ ] 다국어 지원
## 9. 기술 스택
### Backend
- **Node.js + TypeScript**: 백엔드 서버
- **PostgreSQL**: 데이터베이스
- **Prisma**: ORM
- **Puppeteer**: PDF 생성
- **docx**: WORD 생성
### Frontend
- **Next.js + React**: 프론트엔드 프레임워크
- **TypeScript**: 타입 안정성
- **TailwindCSS**: 스타일링
- **react-dnd**: 드래그 앤 드롭
- **react-grid-layout**: 레이아웃 관리
- **react-to-print**: 인쇄 기능
- **react-pdf**: PDF 미리보기
## 10. 보안 고려사항
### 10.1 쿼리 실행 보안
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
- 쿼리 결과 크기 제한 (최대 1000 rows)
- 실행 시간 제한 (30초)
- SQL 인젝션 방지 (파라미터 바인딩 강제)
- 위험한 함수 차단 (DROP, TRUNCATE 등)
### 10.2 파일 보안
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
- 파일은 24시간 후 자동 삭제
- 파일 다운로드 시 토큰 검증
### 10.3 접근 권한
- 리포트 생성/수정/삭제 권한 체크
- 관리자만 템플릿 생성 가능
- 사용자별 리포트 접근 제어
## 11. 성능 최적화
### 11.1 PDF 생성 최적화
- 백그라운드 작업으로 처리
- 생성된 PDF는 CDN에 캐싱
### 11.2 프론트엔드 최적화
- 컴포넌트 가상화 (많은 컴포넌트 처리)
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
- 이미지 레이지 로딩
### 11.3 데이터베이스 최적화
- 레이아웃 데이터는 JSON 형태로 저장
- 리포트 목록 조회 시 인덱스 활용
- 자주 사용하는 템플릿 캐싱
## 12. 테스트 계획
### 12.1 단위 테스트
- API 엔드포인트 테스트
- 쿼리 파싱 테스트
- PDF 생성 테스트
### 12.2 통합 테스트
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
- 템플릿 적용 → 데이터 바인딩 테스트
### 12.3 UI 테스트
- 드래그 앤 드롭 동작 테스트
- 컴포넌트 속성 변경 테스트
## 13. 향후 확장 계획
### 13.1 고급 기능
- 차트/그래프 컴포넌트
- 조건부 서식 (색상 변경 등)
- 그룹핑 및 집계 함수
- 마스터-디테일 관계 자동 설정
### 13.2 협업 기능
- 리포트 공유
- 버전 관리
- 댓글 기능
### 13.3 자동화
- 스케줄링 (정기적 리포트 생성)
- 이메일 자동 발송
- 알림 설정
## 14. 참고 자료
### 14.1 유사 솔루션
- Crystal Reports
- JasperReports
- BIRT (Business Intelligence and Reporting Tools)
- FastReport
### 14.2 라이브러리
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
- [react-dnd](https://react-dnd.github.io/react-dnd/)
- [puppeteer](https://pptr.dev/)
- [docx](https://docx.js.org/)

View File

@ -0,0 +1,371 @@
# 리포트 문서 번호 자동 채번 시스템 설계
## 1. 개요
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
## 2. 문서 번호 형식
### 2.1 기본 형식
```
{PREFIX}-{YEAR}-{SEQUENCE}
예: RPT-2024-0001, INV-2024-0123
```
### 2.2 확장 형식 (선택 사항)
```
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
```
### 2.3 구성 요소
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
- **DEPT_CODE**: 부서 코드 (선택 사항)
- **YEAR**: 연도 (4자리)
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
## 3. 데이터베이스 스키마
### 3.1 문서 번호 규칙 테이블
```sql
-- 문서 번호 규칙 정의
CREATE TABLE report_number_rules (
rule_id SERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
separator VARCHAR(5) DEFAULT '-', -- 구분자
description TEXT, -- 설명
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_by VARCHAR(50)
);
-- 기본 데이터 삽입
INSERT INTO report_number_rules (rule_name, prefix, description)
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
```
### 3.2 문서 번호 시퀀스 테이블
```sql
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
CREATE TABLE report_number_sequences (
sequence_id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
year INTEGER NOT NULL, -- 연도
current_number INTEGER DEFAULT 0, -- 현재 번호
last_generated_at TIMESTAMP, -- 마지막 생성 시각
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
);
```
### 3.3 리포트 테이블 수정
```sql
-- 기존 report_layout 테이블에 컬럼 추가
ALTER TABLE report_layout
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
-- 문서 번호 인덱스 (검색 성능)
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
```
### 3.4 문서 번호 이력 테이블 (감사용)
```sql
-- 문서 번호 생성 이력
CREATE TABLE report_number_history (
history_id SERIAL PRIMARY KEY,
report_id INTEGER REFERENCES report_layout(id),
document_number VARCHAR(100) NOT NULL,
rule_id INTEGER REFERENCES report_number_rules(rule_id),
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
generated_by VARCHAR(50),
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
void_reason TEXT, -- 무효화 사유
voided_at TIMESTAMP,
voided_by VARCHAR(50)
);
-- 문서 번호로 검색 인덱스
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
```
## 4. 백엔드 구현
### 4.1 서비스 레이어 (`reportNumberService.ts`)
```typescript
export class ReportNumberService {
// 문서 번호 생성
static async generateNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
// 문서 번호 형식 검증
static async validateNumber(documentNumber: string): Promise<boolean>;
// 문서 번호 중복 체크
static async isDuplicate(documentNumber: string): Promise<boolean>;
// 문서 번호 무효화
static async voidNumber(
documentNumber: string,
reason: string,
userId: string
): Promise<void>;
// 특정 규칙의 다음 번호 미리보기
static async previewNextNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
}
```
### 4.2 컨트롤러 (`reportNumberController.ts`)
```typescript
// GET /api/report/number-rules - 규칙 목록
// GET /api/report/number-rules/:id - 규칙 상세
// POST /api/report/number-rules - 규칙 생성
// PUT /api/report/number-rules/:id - 규칙 수정
// DELETE /api/report/number-rules/:id - 규칙 삭제
// POST /api/report/:reportId/generate-number - 문서 번호 생성
// POST /api/report/number/preview - 다음 번호 미리보기
// POST /api/report/number/void - 문서 번호 무효화
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
```
### 4.3 핵심 로직 (번호 생성)
```typescript
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
// 1. 트랜잭션 시작
const client = await pool.connect();
try {
await client.query('BEGIN');
// 2. 규칙 조회
const rule = await this.getRule(ruleId);
// 3. 현재 연도/월
const now = new Date();
const year = now.getFullYear();
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
let sequence = await this.getSequence(ruleId, deptCode, year, true);
if (!sequence) {
sequence = await this.createSequence(ruleId, deptCode, year);
}
// 5. 다음 번호 계산
const nextNumber = sequence.current_number + 1;
// 6. 문서 번호 생성
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
// 7. 시퀀스 업데이트
await this.updateSequence(sequence.sequence_id, nextNumber);
// 8. 커밋
await client.query('COMMIT');
return documentNumber;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// 번호 포맷팅
private formatNumber(
rule: NumberRule,
deptCode: string | undefined,
year: number,
sequence: number
): string {
const parts = [rule.prefix];
if (rule.use_dept_code && deptCode) {
parts.push(deptCode);
}
if (rule.use_year) {
parts.push(year.toString());
}
// 0 패딩
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
parts.push(paddedSequence);
return parts.join(rule.separator);
}
```
## 5. 프론트엔드 구현
### 5.1 문서 번호 규칙 관리 화면
**경로**: `/admin/report/number-rules`
**기능**:
- 규칙 목록 조회
- 규칙 생성/수정/삭제
- 규칙 미리보기 (다음 번호 확인)
- 규칙 활성화/비활성화
### 5.2 리포트 목록 화면 수정
**변경 사항**:
- 문서 번호 컬럼 추가
- 문서 번호로 검색 기능
### 5.3 리포트 저장 시 번호 생성
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
```typescript
const saveLayout = async () => {
// 1. 새 리포트인 경우 문서 번호 자동 생성
if (reportId === "new" && !documentNumber) {
const response = await fetch(`/api/report/generate-number`, {
method: "POST",
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
});
const { documentNumber: newNumber } = await response.json();
setDocumentNumber(newNumber);
}
// 2. 리포트 저장 (문서 번호 포함)
await saveReport({ ...reportData, documentNumber });
};
```
### 5.4 문서 번호 표시 UI
**위치**: 디자이너 헤더
```tsx
<div className="document-number">
<Label>문서 번호</Label>
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
</div>
```
## 6. 동시성 제어
### 6.1 문제점
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
### 6.2 해결 방법
**PostgreSQL의 `FOR UPDATE` 사용**
```sql
-- 시퀀스 조회 시 행 락 걸기
SELECT * FROM report_number_sequences
WHERE rule_id = $1 AND year = $2
FOR UPDATE;
```
**트랜잭션 격리 수준**
```typescript
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
```
## 7. 테스트 시나리오
### 7.1 기본 기능 테스트
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
- [ ] 연속 생성 시 순차 번호 증가 확인
- [ ] 연도 변경 시 시퀀스 초기화 확인
### 7.2 동시성 테스트
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
### 7.3 에러 처리
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
- [ ] 비활성화된 규칙 사용 → 경고 메시지
- [ ] 시퀀스 최대값 초과 → 관리자 알림
## 8. 구현 순서
### Phase 1: 데이터베이스 (1단계)
1. 테이블 생성 SQL 작성
2. 마이그레이션 실행
3. 기본 데이터 삽입
### Phase 2: 백엔드 (2단계)
1. `reportNumberService.ts` 구현
2. `reportNumberController.ts` 구현
3. 라우트 추가
4. 단위 테스트
### Phase 3: 프론트엔드 (3단계)
1. 문서 번호 규칙 관리 화면
2. 리포트 목록 화면 수정
3. 디자이너 문서 번호 표시
4. 저장 시 자동 생성 연동
### Phase 4: 테스트 및 최적화 (4단계)
1. 통합 테스트
2. 동시성 테스트
3. 성능 최적화
4. 사용자 가이드 작성
## 9. 향후 확장
### 9.1 고급 기능
- 문서 번호 예약 기능
- 번호 건너뛰기 허용 설정
- 커스텀 포맷 지원 (정규식 기반)
- 연/월/일 단위 초기화 선택
### 9.2 통합
- 승인 완료 시점에 최종 번호 확정
- 외부 시스템과 번호 동기화
- 바코드/QR 코드 자동 생성
## 10. 보안 고려사항
- 문서 번호 생성 권한 제한
- 번호 무효화 감사 로그
- 시퀀스 직접 수정 방지
- API 호출 횟수 제한 (Rate Limiting)

View File

@ -0,0 +1,388 @@
# 리포트 페이지 관리 시스템 설계
## 1. 개요
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
## 2. 주요 기능
### 2.1 페이지 관리
- 페이지 추가/삭제
- 페이지 복사
- 페이지 순서 변경 (드래그 앤 드롭)
- 페이지 이름 지정
### 2.2 페이지 네비게이션
- 좌측 페이지 썸네일 패널
- 페이지 간 전환 (클릭)
- 이전/다음 페이지 이동
- 페이지 번호 표시
### 2.3 페이지별 설정
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
- 페이지 방향 (세로/가로)
- 여백 설정
- 배경색
### 2.4 컴포넌트 관리
- 컴포넌트는 특정 페이지에 속함
- 페이지 간 컴포넌트 복사/이동
- 현재 페이지의 컴포넌트만 표시
## 3. 데이터베이스 스키마
### 3.1 기존 구조 활용 (변경 없음)
**report_layout 테이블의 layout_config (JSONB) 활용**
기존:
```json
{
"width": 210,
"height": 297,
"orientation": "portrait",
"components": [...]
}
```
변경 후:
```json
{
"pages": [
{
"page_id": "page-uuid-1",
"page_name": "표지",
"page_order": 0,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": {
"top": 20,
"bottom": 20,
"left": 20,
"right": 20
},
"background_color": "#ffffff",
"components": [
{
"id": "comp-1",
"type": "text",
"x": 100,
"y": 50,
...
}
]
},
{
"page_id": "page-uuid-2",
"page_name": "본문",
"page_order": 1,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
"background_color": "#ffffff",
"components": [...]
}
]
}
```
### 3.2 마이그레이션 전략
기존 단일 페이지 리포트 자동 변환:
```typescript
// 기존 구조 감지 시
if (layoutConfig.components && !layoutConfig.pages) {
// 자동으로 pages 구조로 변환
layoutConfig = {
pages: [
{
page_id: uuidv4(),
page_name: "페이지 1",
page_order: 0,
width: layoutConfig.width || 210,
height: layoutConfig.height || 297,
orientation: layoutConfig.orientation || "portrait",
margins: { top: 20, bottom: 20, left: 20, right: 20 },
background_color: "#ffffff",
components: layoutConfig.components,
},
],
};
}
```
## 4. 프론트엔드 구조
### 4.1 타입 정의 (types/report.ts)
```typescript
export interface ReportPage {
page_id: string;
report_id: string;
page_order: number;
page_name: string;
// 페이지 설정
width: number;
height: number;
orientation: 'portrait' | 'landscape';
// 여백
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
// 배경
background_color: string;
created_at?: string;
updated_at?: string;
}
export interface ComponentConfig {
id: string;
// page_id 불필요 (페이지의 components 배열에 포함됨)
type: 'text' | 'label' | 'image' | 'table' | ...;
x: number;
y: number;
width: number;
height: number;
// ... 기타 속성
}
export interface ReportLayoutConfig {
pages: ReportPage[];
}
```
### 4.2 Context 구조 변경
```typescript
interface ReportDesignerContextType {
// 페이지 관리
pages: ReportPage[];
currentPageId: string | null;
currentPage: ReportPage | null;
addPage: () => void;
deletePage: (pageId: string) => void;
duplicatePage: (pageId: string) => void;
reorderPages: (sourceIndex: number, targetIndex: number) => void;
selectPage: (pageId: string) => void;
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
// 컴포넌트 (현재 페이지만)
currentPageComponents: ComponentConfig[];
// ... 기존 기능들
}
```
### 4.3 UI 구조
```
┌─────────────────────────────────────────────────────────────┐
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
├──────────┬────────────────────────────────────┬─────────────┤
│ │ │ │
│ PageList │ ReportDesignerCanvas │ Right │
│ (좌측) │ (현재 페이지만 표시) │ Panel │
│ │ │ (속성) │
│ - Page 1 │ ┌──────────────────────────┐ │ │
│ - Page 2 │ │ │ │ │
│ * Page 3 │ │ [컴포넌트들] │ │ │
│ (현재) │ │ │ │ │
│ │ └──────────────────────────┘ │ │
│ [+ 추가] │ │ │
│ │ 이전 | 다음 (페이지 네비게이션) │ │
└──────────┴────────────────────────────────────┴─────────────┘
```
## 5. 컴포넌트 구조
### 5.1 새 컴포넌트
#### PageListPanel.tsx
```typescript
- 좌측 페이지 목록 패널
- 페이지 썸네일 표시
- 드래그 앤 드롭으로 순서 변경
- 페이지 추가/삭제/복사 버튼
- 현재 페이지 하이라이트
```
#### PageNavigator.tsx
```typescript
- 캔버스 하단의 페이지 네비게이션
- 이전/다음 버튼
- 현재 페이지 번호 표시
- 페이지 점프 (1/5 형식)
```
#### PageSettingsPanel.tsx
```typescript
- 우측 패널 내 페이지 설정 섹션
- 페이지 크기, 방향
- 여백 설정
- 배경색
```
### 5.2 수정할 컴포넌트
#### ReportDesignerContext.tsx
- pages 상태 추가
- currentPageId 상태 추가
- 페이지 관리 함수들 추가
- components를 currentPageComponents로 필터링
#### ReportDesignerCanvas.tsx
- currentPageComponents만 렌더링
- 캔버스 크기를 currentPage 기준으로 설정
- 컴포넌트 추가 시 page_id 포함
#### ReportDesignerToolbar.tsx
- "페이지 추가" 버튼 추가
- 저장 시 pages도 함께 저장
#### ReportPreviewModal.tsx
- 모든 페이지 순서대로 미리보기
- 페이지 구분선 표시
- PDF 저장 시 모든 페이지 포함
## 6. API 엔드포인트
### 6.1 페이지 관리
```typescript
// 페이지 목록 조회
GET /api/report/:reportId/pages
Response: { pages: ReportPage[] }
// 페이지 생성
POST /api/report/:reportId/pages
Body: { page_name, width, height, orientation, margins }
Response: { page: ReportPage }
// 페이지 수정
PUT /api/report/pages/:pageId
Body: Partial<ReportPage>
Response: { page: ReportPage }
// 페이지 삭제
DELETE /api/report/pages/:pageId
Response: { success: boolean }
// 페이지 순서 변경
PUT /api/report/:reportId/pages/reorder
Body: { pageOrders: Array<{ page_id, page_order }> }
Response: { success: boolean }
// 페이지 복사
POST /api/report/pages/:pageId/duplicate
Response: { page: ReportPage }
```
### 6.2 레이아웃 (기존 수정)
```typescript
// 레이아웃 저장 (페이지별)
PUT /api/report/:reportId/layout
Body: {
pages: ReportPage[],
components: ComponentConfig[] // page_id 포함
}
```
## 7. 구현 단계
### Phase 1: DB 및 백엔드 (0.5일)
1. ✅ DB 스키마 생성
2. ✅ API 엔드포인트 구현
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
### Phase 2: 타입 및 Context (0.5일)
1. ✅ 타입 정의 업데이트
2. ✅ Context에 페이지 상태/함수 추가
3. ✅ API 연동
### Phase 3: UI 컴포넌트 (1일)
1. ✅ PageListPanel 구현
2. ✅ PageNavigator 구현
3. ✅ PageSettingsPanel 구현
### Phase 4: 통합 및 수정 (1일)
1. ✅ Canvas에서 현재 페이지만 표시
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
3. ✅ 미리보기에서 모든 페이지 표시
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
### Phase 5: 테스트 및 최적화 (0.5일)
1. ✅ 페이지 전환 성능 확인
2. ✅ 썸네일 렌더링 최적화
3. ✅ 버그 수정
**총 예상 기간: 3-4일**
## 8. 주의사항
### 8.1 성능 최적화
- 페이지 썸네일은 저해상도로 렌더링
- 현재 페이지 컴포넌트만 DOM에 유지
- 페이지 전환 시 애니메이션 최소화
### 8.2 호환성
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
- 템플릿도 페이지 구조 포함
### 8.3 사용자 경험
- 페이지 삭제 시 확인 다이얼로그
- 컴포넌트가 있는 페이지 삭제 시 경고
- 페이지 순서 변경 시 즉시 반영
## 9. 추후 확장 기능
### 9.1 페이지 템플릿
- 자주 사용하는 페이지 레이아웃 저장
- 페이지 추가 시 템플릿 선택
### 9.2 마스터 페이지
- 모든 페이지에 공통으로 적용되는 헤더/푸터
- 페이지 번호 자동 삽입
### 9.3 페이지 연결
- 테이블 데이터가 여러 페이지에 자동 분할
- 페이지 오버플로우 처리
## 10. 참고 자료
- 오즈리포트 메뉴얼
- Crystal Reports 페이지 관리
- Adobe InDesign 페이지 시스템

View File

@ -1,513 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
// 기본 변형 목록
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
export default function EditButtonActionPage() {
const params = useParams();
const router = useRouter();
const actionType = params.actionType as string;
const { buttonActions, updateButtonAction, isUpdating, updateError, isLoading } = useButtonActions();
const [formData, setFormData] = useState<Partial<ButtonActionFormData>>({});
const [originalData, setOriginalData] = useState<any>(null);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [jsonErrors, setJsonErrors] = useState<{
validation_rules?: string;
action_config?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
validation_rules: "{}",
action_config: "{}",
});
// 버튼 액션 데이터 로드
useEffect(() => {
if (buttonActions && actionType && !isDataLoaded) {
const found = buttonActions.find((ba) => ba.action_type === actionType);
if (found) {
setOriginalData(found);
setFormData({
action_name: found.action_name,
action_name_eng: found.action_name_eng || "",
description: found.description || "",
category: found.category,
default_text: found.default_text || "",
default_text_eng: found.default_text_eng || "",
default_icon: found.default_icon || "",
default_color: found.default_color || "",
default_variant: found.default_variant || "default",
confirmation_required: found.confirmation_required || false,
confirmation_message: found.confirmation_message || "",
validation_rules: found.validation_rules || {},
action_config: found.action_config || {},
sort_order: found.sort_order || 0,
is_active: found.is_active,
});
setJsonStrings({
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
action_config: JSON.stringify(found.action_config || {}, null, 2),
});
setIsDataLoaded(true);
} else {
toast.error("버튼 액션을 찾을 수 없습니다.");
router.push("/admin/system-settings/button-actions");
}
}
}, [buttonActions, actionType, isDataLoaded, router]);
// 입력값 변경 핸들러
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.action_name?.trim()) {
toast.error("액션명을 입력해주세요.");
return false;
}
if (!formData.category?.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await updateButtonAction(actionType, formData);
toast.success("버튼 액션이 성공적으로 수정되었습니다.");
router.push(`/admin/system-settings/button-actions/${actionType}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
}
};
// 폼 초기화 (원본 데이터로 되돌리기)
const handleReset = () => {
if (originalData) {
setFormData({
action_name: originalData.action_name,
action_name_eng: originalData.action_name_eng || "",
description: originalData.description || "",
category: originalData.category,
default_text: originalData.default_text || "",
default_text_eng: originalData.default_text_eng || "",
default_icon: originalData.default_icon || "",
default_color: originalData.default_color || "",
default_variant: originalData.default_variant || "default",
confirmation_required: originalData.confirmation_required || false,
confirmation_message: originalData.confirmation_message || "",
validation_rules: originalData.validation_rules || {},
action_config: originalData.action_config || {},
sort_order: originalData.sort_order || 0,
is_active: originalData.is_active,
});
setJsonStrings({
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
action_config: JSON.stringify(originalData.action_config || {}, null, 2),
});
setJsonErrors({});
}
};
// 로딩 상태
if (isLoading || !isDataLoaded) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 버튼 액션을 찾지 못한 경우
if (!originalData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<Badge variant="outline" className="font-mono">
{actionType}
</Badge>
</div>
<p className="text-muted-foreground">{originalData.action_name} .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 액션 타입 (읽기 전용) */}
<div className="space-y-2">
<Label htmlFor="action_type"> </Label>
<Input id="action_type" value={actionType} disabled className="bg-muted font-mono" />
<p className="text-muted-foreground text-xs"> .</p>
</div>
{/* 액션명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="action_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_name"
value={formData.action_name || ""}
onChange={(e) => handleInputChange("action_name", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action_name_eng"></Label>
<Input
id="action_name_eng"
value={formData.action_name_eng || ""}
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order || 0}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* 기본 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 기본 텍스트 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_text"> </Label>
<Input
id="default_text"
value={formData.default_text || ""}
onChange={(e) => handleInputChange("default_text", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_text_eng"> </Label>
<Input
id="default_text_eng"
value={formData.default_text_eng || ""}
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 아이콘 및 색상 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_icon"> </Label>
<Input
id="default_icon"
value={formData.default_icon || ""}
onChange={(e) => handleInputChange("default_icon", e.target.value)}
placeholder="예: Save (Lucide 아이콘명)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_color"> </Label>
<Input
id="default_color"
value={formData.default_color || ""}
onChange={(e) => handleInputChange("default_color", e.target.value)}
placeholder="예: blue, red, green..."
/>
</div>
</div>
{/* 변형 */}
<div className="space-y-2">
<Label htmlFor="default_variant"> </Label>
<Select
value={formData.default_variant || "default"}
onValueChange={(value) => handleInputChange("default_variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="변형 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_VARIANTS.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 확인 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="confirmation_required"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="confirmation_required"
checked={formData.confirmation_required || false}
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
/>
</div>
{formData.confirmation_required && (
<div className="space-y-2">
<Label htmlFor="confirmation_message"> </Label>
<Textarea
id="confirmation_message"
value={formData.confirmation_message || ""}
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
placeholder="예: 정말로 삭제하시겠습니까?"
rows={2}
/>
</div>
)}
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"requiresData": true, "minItems": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 액션 설정 */}
<div className="space-y-2">
<Label htmlFor="action_config"> </Label>
<Textarea
id="action_config"
value={jsonStrings.action_config}
onChange={(e) => handleJsonChange("action_config", e.target.value)}
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex items-center justify-between">
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
</Button>
</Link>
<div className="flex gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isUpdating}>
<Save className="mr-2 h-4 w-4" />
{isUpdating ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 에러 메시지 */}
{updateError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,344 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { ArrowLeft, Edit, Settings, Code, Eye, CheckCircle, AlertCircle } from "lucide-react";
import { useButtonActions } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
export default function ButtonActionDetailPage() {
const params = useParams();
const router = useRouter();
const actionType = params.actionType as string;
const { buttonActions, isLoading, error } = useButtonActions();
const [actionData, setActionData] = useState<any>(null);
// 버튼 액션 데이터 로드
useEffect(() => {
if (buttonActions && actionType) {
const found = buttonActions.find((ba) => ba.action_type === actionType);
if (found) {
setActionData(found);
} else {
toast.error("버튼 액션을 찾을 수 없습니다.");
router.push("/admin/system-settings/button-actions");
}
}
}, [buttonActions, actionType, router]);
// JSON 포맷팅 함수
const formatJson = (obj: any): string => {
if (!obj || typeof obj !== "object") return "{}";
try {
return JSON.stringify(obj, null, 2);
} catch {
return "{}";
}
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
// 버튼 액션을 찾지 못한 경우
if (!actionData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/button-actions">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/system-settings/button-actions">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{actionData.action_name}</h1>
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
{actionData.confirmation_required && (
<Badge variant="outline" className="text-orange-600">
<AlertCircle className="mr-1 h-3 w-3" />
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-4">
<p className="text-muted-foreground font-mono">{actionData.action_type}</p>
{actionData.action_name_eng && <p className="text-muted-foreground">{actionData.action_name_eng}</p>}
</div>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/system-settings/button-actions/${actionType}/edit`}>
<Button>
<Edit className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="config" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="json" className="flex items-center gap-2">
<Code className="h-4 w-4" />
JSON
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono text-lg">{actionData.action_type}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{actionData.action_name}</dd>
</div>
{actionData.action_name_eng && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{actionData.action_name_eng}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant="secondary">{actionData.category}</Badge>
</dd>
</div>
{actionData.description && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-muted-foreground text-sm">{actionData.description}</dd>
</div>
)}
</CardContent>
</Card>
{/* 기본 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{actionData.default_text && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{actionData.default_text}</dd>
{actionData.default_text_eng && (
<dd className="text-muted-foreground text-sm">{actionData.default_text_eng}</dd>
)}
</div>
)}
{actionData.default_icon && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono">{actionData.default_icon}</dd>
</div>
)}
{actionData.default_color && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd>
<Badge
variant="outline"
style={{
borderColor: actionData.default_color,
color: actionData.default_color,
}}
>
{actionData.default_color}
</Badge>
</dd>
</div>
)}
{actionData.default_variant && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd>
<Badge variant="outline">{actionData.default_variant}</Badge>
</dd>
</div>
)}
</CardContent>
</Card>
{/* 확인 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="flex items-center gap-2">
{actionData.confirmation_required ? (
<>
<AlertCircle className="h-4 w-4 text-orange-600" />
<span className="text-orange-600"></span>
</>
) : (
<>
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="text-green-600"></span>
</>
)}
</dd>
</div>
{actionData.confirmation_required && actionData.confirmation_message && (
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="bg-muted rounded-md p-3 text-sm">{actionData.confirmation_message}</dd>
</div>
)}
</CardContent>
</Card>
{/* 메타데이터 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{actionData.sort_order || 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">
{actionData.created_date ? new Date(actionData.created_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{actionData.created_by || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-sm">
{actionData.updated_date ? new Date(actionData.updated_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{actionData.updated_by || "-"}</dd>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="config" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(actionData.validation_rules)}
</pre>
</CardContent>
</Card>
{/* 액션 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(actionData.action_config)}
</pre>
</CardContent>
</Card>
</div>
</TabsContent>
{/* JSON 데이터 탭 */}
<TabsContent value="json" className="space-y-6">
<Card>
<CardHeader>
<CardTitle> JSON </CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(actionData)}</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,466 +0,0 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
// 기본 변형 목록
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
export default function NewButtonActionPage() {
const router = useRouter();
const { createButtonAction, isCreating, createError } = useButtonActions();
const [formData, setFormData] = useState<ButtonActionFormData>({
action_type: "",
action_name: "",
action_name_eng: "",
description: "",
category: "general",
default_text: "",
default_text_eng: "",
default_icon: "",
default_color: "",
default_variant: "default",
confirmation_required: false,
confirmation_message: "",
validation_rules: {},
action_config: {},
sort_order: 0,
is_active: "Y",
});
const [jsonErrors, setJsonErrors] = useState<{
validation_rules?: string;
action_config?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
validation_rules: "{}",
action_config: "{}",
});
// 입력값 변경 핸들러
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.action_type.trim()) {
toast.error("액션 타입을 입력해주세요.");
return false;
}
if (!formData.action_name.trim()) {
toast.error("액션명을 입력해주세요.");
return false;
}
if (!formData.category.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await createButtonAction(formData);
toast.success("버튼 액션이 성공적으로 생성되었습니다.");
router.push("/admin/system-settings/button-actions");
} catch (error) {
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
}
};
// 폼 초기화
const handleReset = () => {
setFormData({
action_type: "",
action_name: "",
action_name_eng: "",
description: "",
category: "general",
default_text: "",
default_text_eng: "",
default_icon: "",
default_color: "",
default_variant: "default",
confirmation_required: false,
confirmation_message: "",
validation_rules: {},
action_config: {},
sort_order: 0,
is_active: "Y",
});
setJsonStrings({
validation_rules: "{}",
action_config: "{}",
});
setJsonErrors({});
};
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href="/admin/system-settings/button-actions">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 액션 타입 */}
<div className="space-y-2">
<Label htmlFor="action_type">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_type"
value={formData.action_type}
onChange={(e) => handleInputChange("action_type", e.target.value)}
placeholder="예: save, delete, edit..."
className="font-mono"
/>
<p className="text-muted-foreground text-xs"> , , (_) .</p>
</div>
{/* 액션명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="action_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="action_name"
value={formData.action_name}
onChange={(e) => handleInputChange("action_name", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action_name_eng"></Label>
<Input
id="action_name_eng"
value={formData.action_name_eng}
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* 기본 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* 기본 텍스트 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_text"> </Label>
<Input
id="default_text"
value={formData.default_text}
onChange={(e) => handleInputChange("default_text", e.target.value)}
placeholder="예: 저장"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_text_eng"> </Label>
<Input
id="default_text_eng"
value={formData.default_text_eng}
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
placeholder="예: Save"
/>
</div>
</div>
{/* 아이콘 및 색상 */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="default_icon"> </Label>
<Input
id="default_icon"
value={formData.default_icon}
onChange={(e) => handleInputChange("default_icon", e.target.value)}
placeholder="예: Save (Lucide 아이콘명)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_color"> </Label>
<Input
id="default_color"
value={formData.default_color}
onChange={(e) => handleInputChange("default_color", e.target.value)}
placeholder="예: blue, red, green..."
/>
</div>
</div>
{/* 변형 */}
<div className="space-y-2">
<Label htmlFor="default_variant"> </Label>
<Select
value={formData.default_variant}
onValueChange={(value) => handleInputChange("default_variant", value)}
>
<SelectTrigger>
<SelectValue placeholder="변형 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_VARIANTS.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 확인 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="confirmation_required"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="confirmation_required"
checked={formData.confirmation_required}
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
/>
</div>
{formData.confirmation_required && (
<div className="space-y-2">
<Label htmlFor="confirmation_message"> </Label>
<Textarea
id="confirmation_message"
value={formData.confirmation_message}
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
placeholder="예: 정말로 삭제하시겠습니까?"
rows={2}
/>
</div>
)}
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"requiresData": true, "minItems": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 액션 설정 */}
<div className="space-y-2">
<Label htmlFor="action_config"> </Label>
<Textarea
id="action_config"
value={jsonStrings.action_config}
onChange={(e) => handleJsonChange("action_config", e.target.value)}
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isCreating}>
<Save className="mr-2 h-4 w-4" />
{isCreating ? "생성 중..." : "저장"}
</Button>
</div>
{/* 에러 메시지 */}
{createError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,376 +0,0 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import {
Plus,
Search,
Edit,
Trash2,
Eye,
Filter,
RotateCcw,
Settings,
SortAsc,
SortDesc,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useButtonActions } from "@/hooks/admin/useButtonActions";
import Link from "next/link";
export default function ButtonActionsManagePage() {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [activeFilter, setActiveFilter] = useState<string>("Y");
const [sortField, setSortField] = useState<string>("sort_order");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
// 버튼 액션 데이터 조회
const { buttonActions, isLoading, error, deleteButtonAction, isDeleting, deleteError, refetch } = useButtonActions({
active: activeFilter || undefined,
search: searchTerm || undefined,
category: categoryFilter || undefined,
});
// 카테고리 목록 생성
const categories = useMemo(() => {
const uniqueCategories = Array.from(new Set(buttonActions.map((ba) => ba.category).filter(Boolean)));
return uniqueCategories.sort();
}, [buttonActions]);
// 필터링 및 정렬된 데이터
const filteredAndSortedButtonActions = useMemo(() => {
let filtered = [...buttonActions];
// 정렬
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof typeof a];
let bValue: any = b[sortField as keyof typeof b];
// 숫자 필드 처리
if (sortField === "sort_order") {
aValue = aValue || 0;
bValue = bValue || 0;
}
// 문자열 필드 처리
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
}
if (typeof bValue === "string") {
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [buttonActions, sortField, sortDirection]);
// 정렬 변경 핸들러
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 삭제 핸들러
const handleDelete = async (actionType: string, actionName: string) => {
try {
await deleteButtonAction(actionType);
toast.success(`버튼 액션 '${actionName}'이 삭제되었습니다.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
}
};
// 필터 초기화
const resetFilters = () => {
setSearchTerm("");
setCategoryFilter("");
setActiveFilter("Y");
setSortField("sort_order");
setSortDirection("asc");
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Button onClick={() => refetch()} variant="outline">
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Link href="/admin/system-settings/button-actions/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 필터 및 검색 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input
placeholder="액션명, 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성화 상태 필터 */}
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
{/* 초기화 버튼 */}
<Button variant="outline" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 결과 통계 */}
<div className="mb-4">
<p className="text-muted-foreground text-sm">
{filteredAndSortedButtonActions.length} .
</p>
</div>
{/* 버튼 액션 목록 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
<div className="flex items-center gap-2">
{sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_type")}>
<div className="flex items-center gap-2">
{sortField === "action_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_name")}>
<div className="flex items-center gap-2">
{sortField === "action_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
<div className="flex items-center gap-2">
{sortField === "category" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
<div className="flex items-center gap-2">
{sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
<div className="flex items-center gap-2">
{sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedButtonActions.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="py-8 text-center">
.
</TableCell>
</TableRow>
) : (
filteredAndSortedButtonActions.map((action) => (
<TableRow key={action.action_type}>
<TableCell className="font-mono">{action.sort_order || 0}</TableCell>
<TableCell className="font-mono">{action.action_type}</TableCell>
<TableCell className="font-medium">
{action.action_name}
{action.action_name_eng && (
<div className="text-muted-foreground text-xs">{action.action_name_eng}</div>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{action.category}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">{action.default_text || "-"}</TableCell>
<TableCell>
{action.confirmation_required ? (
<div className="flex items-center gap-1 text-orange-600">
<AlertCircle className="h-4 w-4" />
<span className="text-xs"></span>
</div>
) : (
<div className="flex items-center gap-1 text-gray-500">
<CheckCircle className="h-4 w-4" />
<span className="text-xs"></span>
</div>
)}
</TableCell>
<TableCell className="max-w-xs truncate">{action.description || "-"}</TableCell>
<TableCell>
<Badge variant={action.is_active === "Y" ? "default" : "secondary"}>
{action.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{action.updated_date ? new Date(action.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link href={`/admin/system-settings/button-actions/${action.action_type}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/system-settings/button-actions/${action.action_type}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
'{action.action_name}' ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(action.action_type, action.action_name)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{deleteError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,430 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
export default function EditWebTypePage() {
const params = useParams();
const router = useRouter();
const webType = params.webType as string;
const { webTypes, updateWebType, isUpdating, updateError, isLoading } = useWebTypes();
const [formData, setFormData] = useState<Partial<WebTypeFormData>>({});
const [originalData, setOriginalData] = useState<any>(null);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [jsonErrors, setJsonErrors] = useState<{
default_config?: string;
validation_rules?: string;
default_style?: string;
input_properties?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
// 웹타입 데이터 로드
useEffect(() => {
if (webTypes && webType && !isDataLoaded) {
const found = webTypes.find((wt) => wt.web_type === webType);
if (found) {
setOriginalData(found);
setFormData({
type_name: found.type_name,
type_name_eng: found.type_name_eng || "",
description: found.description || "",
category: found.category,
default_config: found.default_config || {},
validation_rules: found.validation_rules || {},
default_style: found.default_style || {},
input_properties: found.input_properties || {},
sort_order: found.sort_order || 0,
is_active: found.is_active,
});
setJsonStrings({
default_config: JSON.stringify(found.default_config || {}, null, 2),
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
default_style: JSON.stringify(found.default_style || {}, null, 2),
input_properties: JSON.stringify(found.input_properties || {}, null, 2),
});
setIsDataLoaded(true);
} else {
toast.error("웹타입을 찾을 수 없습니다.");
router.push("/admin/system-settings/web-types");
}
}
}, [webTypes, webType, isDataLoaded, router]);
// 입력값 변경 핸들러
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
value: string,
) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.type_name?.trim()) {
toast.error("웹타입명을 입력해주세요.");
return false;
}
if (!formData.category?.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await updateWebType(webType, formData);
toast.success("웹타입이 성공적으로 수정되었습니다.");
router.push(`/admin/system-settings/web-types/${webType}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
}
};
// 폼 초기화 (원본 데이터로 되돌리기)
const handleReset = () => {
if (originalData) {
setFormData({
type_name: originalData.type_name,
type_name_eng: originalData.type_name_eng || "",
description: originalData.description || "",
category: originalData.category,
default_config: originalData.default_config || {},
validation_rules: originalData.validation_rules || {},
default_style: originalData.default_style || {},
input_properties: originalData.input_properties || {},
sort_order: originalData.sort_order || 0,
is_active: originalData.is_active,
});
setJsonStrings({
default_config: JSON.stringify(originalData.default_config || {}, null, 2),
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
default_style: JSON.stringify(originalData.default_style || {}, null, 2),
input_properties: JSON.stringify(originalData.input_properties || {}, null, 2),
});
setJsonErrors({});
}
};
// 로딩 상태
if (isLoading || !isDataLoaded) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 웹타입을 찾지 못한 경우
if (!originalData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href={`/admin/system-settings/web-types/${webType}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<Badge variant="outline" className="font-mono">
{webType}
</Badge>
</div>
<p className="text-muted-foreground">{originalData.type_name} .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 웹타입 코드 (읽기 전용) */}
<div className="space-y-2">
<Label htmlFor="web_type"> </Label>
<Input id="web_type" value={webType} disabled className="bg-muted font-mono" />
<p className="text-muted-foreground text-xs"> .</p>
</div>
{/* 웹타입명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="type_name"
value={formData.type_name || ""}
onChange={(e) => handleInputChange("type_name", e.target.value)}
placeholder="예: 텍스트 입력"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type_name_eng"></Label>
<Input
id="type_name_eng"
value={formData.type_name_eng || ""}
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
placeholder="예: Text Input"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="웹타입에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order || 0}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<div className="space-y-2">
<Label htmlFor="default_config"> </Label>
<Textarea
id="default_config"
value={jsonStrings.default_config}
onChange={(e) => handleJsonChange("default_config", e.target.value)}
placeholder='{"placeholder": "입력하세요..."}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
</div>
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"required": true, "minLength": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 기본 스타일 */}
<div className="space-y-2">
<Label htmlFor="default_style"> </Label>
<Textarea
id="default_style"
value={jsonStrings.default_style}
onChange={(e) => handleJsonChange("default_style", e.target.value)}
placeholder='{"width": "100%", "height": "40px"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
</div>
{/* 입력 속성 */}
<div className="space-y-2">
<Label htmlFor="input_properties">HTML </Label>
<Textarea
id="input_properties"
value={jsonStrings.input_properties}
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
placeholder='{"type": "text", "autoComplete": "off"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex items-center justify-between">
<Link href={`/admin/system-settings/web-types/${webType}`}>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
</Button>
</Link>
<div className="flex gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isUpdating}>
<Save className="mr-2 h-4 w-4" />
{isUpdating ? "저장 중..." : "저장"}
</Button>
</div>
</div>
{/* 에러 메시지 */}
{updateError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,285 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { ArrowLeft, Edit, Settings, Code, Eye } from "lucide-react";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
export default function WebTypeDetailPage() {
const params = useParams();
const router = useRouter();
const webType = params.webType as string;
const { webTypes, isLoading, error } = useWebTypes();
const [webTypeData, setWebTypeData] = useState<any>(null);
// 웹타입 데이터 로드
useEffect(() => {
if (webTypes && webType) {
const found = webTypes.find((wt) => wt.web_type === webType);
if (found) {
setWebTypeData(found);
} else {
toast.error("웹타입을 찾을 수 없습니다.");
router.push("/admin/system-settings/web-types");
}
}
}, [webTypes, webType, router]);
// JSON 포맷팅 함수
const formatJson = (obj: any): string => {
if (!obj || typeof obj !== "object") return "{}";
try {
return JSON.stringify(obj, null, 2);
} catch {
return "{}";
}
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
// 웹타입을 찾지 못한 경우
if (!webTypeData) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="text-muted-foreground mb-2 text-lg"> .</div>
<Link href="/admin/system-settings/web-types">
<Button variant="outline"> </Button>
</Link>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/system-settings/web-types">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{webTypeData.type_name}</h1>
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
<div className="mt-1 flex items-center gap-4">
<p className="text-muted-foreground font-mono">{webTypeData.web_type}</p>
{webTypeData.type_name_eng && <p className="text-muted-foreground">{webTypeData.type_name_eng}</p>}
</div>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/system-settings/web-types/${webType}/edit`}>
<Button>
<Edit className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="config" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="json" className="flex items-center gap-2">
<Code className="h-4 w-4" />
JSON
</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="font-mono text-lg">{webTypeData.web_type}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{webTypeData.type_name}</dd>
</div>
{webTypeData.type_name_eng && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-lg">{webTypeData.type_name_eng}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant="secondary">{webTypeData.category}</Badge>
</dd>
</div>
{webTypeData.description && (
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-muted-foreground text-sm">{webTypeData.description}</dd>
</div>
)}
</CardContent>
</Card>
{/* 메타데이터 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-lg">{webTypeData.sort_order || 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd>
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">
{webTypeData.created_date ? new Date(webTypeData.created_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{webTypeData.created_by || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"> </dt>
<dd className="text-sm">
{webTypeData.updated_date ? new Date(webTypeData.updated_date).toLocaleString("ko-KR") : "-"}
</dd>
</div>
<div>
<dt className="text-muted-foreground text-sm font-medium"></dt>
<dd className="text-sm">{webTypeData.updated_by || "-"}</dd>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="config" className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.default_config)}
</pre>
</CardContent>
</Card>
{/* 유효성 검사 규칙 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.validation_rules)}
</pre>
</CardContent>
</Card>
{/* 기본 스타일 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.default_style)}
</pre>
</CardContent>
</Card>
{/* HTML 입력 속성 */}
<Card>
<CardHeader>
<CardTitle>HTML </CardTitle>
<CardDescription>HTML .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
{formatJson(webTypeData.input_properties)}
</pre>
</CardContent>
</Card>
</div>
</TabsContent>
{/* JSON 데이터 탭 */}
<TabsContent value="json" className="space-y-6">
<Card>
<CardHeader>
<CardTitle> JSON </CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(webTypeData)}</pre>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,381 +0,0 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
// 기본 카테고리 목록
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
export default function NewWebTypePage() {
const router = useRouter();
const { createWebType, isCreating, createError } = useWebTypes();
const [formData, setFormData] = useState<WebTypeFormData>({
web_type: "",
type_name: "",
type_name_eng: "",
description: "",
category: "input",
default_config: {},
validation_rules: {},
default_style: {},
input_properties: {},
sort_order: 0,
is_active: "Y",
});
const [jsonErrors, setJsonErrors] = useState<{
default_config?: string;
validation_rules?: string;
default_style?: string;
input_properties?: string;
}>({});
// JSON 문자열 상태 (편집용)
const [jsonStrings, setJsonStrings] = useState({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
// 입력값 변경 핸들러
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// JSON 입력 변경 핸들러
const handleJsonChange = (
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
value: string,
) => {
setJsonStrings((prev) => ({
...prev,
[field]: value,
}));
// JSON 파싱 시도
try {
const parsed = value.trim() ? JSON.parse(value) : {};
setFormData((prev) => ({
...prev,
[field]: parsed,
}));
setJsonErrors((prev) => ({
...prev,
[field]: undefined,
}));
} catch (error) {
setJsonErrors((prev) => ({
...prev,
[field]: "유효하지 않은 JSON 형식입니다.",
}));
}
};
// 폼 유효성 검사
const validateForm = (): boolean => {
if (!formData.web_type.trim()) {
toast.error("웹타입 코드를 입력해주세요.");
return false;
}
if (!formData.type_name.trim()) {
toast.error("웹타입명을 입력해주세요.");
return false;
}
if (!formData.category.trim()) {
toast.error("카테고리를 선택해주세요.");
return false;
}
// JSON 에러가 있는지 확인
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
if (hasJsonErrors) {
toast.error("JSON 형식 오류를 수정해주세요.");
return false;
}
return true;
};
// 저장 핸들러
const handleSave = async () => {
if (!validateForm()) return;
try {
await createWebType(formData);
toast.success("웹타입이 성공적으로 생성되었습니다.");
router.push("/admin/system-settings/web-types");
} catch (error) {
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
}
};
// 폼 초기화
const handleReset = () => {
setFormData({
web_type: "",
type_name: "",
type_name_eng: "",
description: "",
category: "input",
default_config: {},
validation_rules: {},
default_style: {},
input_properties: {},
sort_order: 0,
is_active: "Y",
});
setJsonStrings({
default_config: "{}",
validation_rules: "{}",
default_style: "{}",
input_properties: "{}",
});
setJsonErrors({});
};
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center gap-4">
<Link href="/admin/system-settings/web-types">
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 기본 정보 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 웹타입 코드 */}
<div className="space-y-2">
<Label htmlFor="web_type">
<span className="text-red-500">*</span>
</Label>
<Input
id="web_type"
value={formData.web_type}
onChange={(e) => handleInputChange("web_type", e.target.value)}
placeholder="예: text, number, email..."
className="font-mono"
/>
<p className="text-muted-foreground text-xs"> , , (_) .</p>
</div>
{/* 웹타입명 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type_name">
<span className="text-red-500">*</span>
</Label>
<Input
id="type_name"
value={formData.type_name}
onChange={(e) => handleInputChange("type_name", e.target.value)}
placeholder="예: 텍스트 입력"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type_name_eng"></Label>
<Input
id="type_name_eng"
value={formData.type_name_eng}
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
placeholder="예: Text Input"
/>
</div>
</div>
{/* 카테고리 */}
<div className="space-y-2">
<Label htmlFor="category">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="웹타입에 대한 설명을 입력해주세요..."
rows={3}
/>
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
placeholder="0"
min="0"
/>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</CardContent>
</Card>
{/* 상태 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="is_active"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="is_active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
/>
</div>
<div className="mt-4">
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
{formData.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</div>
</CardContent>
</Card>
{/* JSON 설정 */}
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle> (JSON)</CardTitle>
<CardDescription> JSON .</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* 기본 설정 */}
<div className="space-y-2">
<Label htmlFor="default_config"> </Label>
<Textarea
id="default_config"
value={jsonStrings.default_config}
onChange={(e) => handleJsonChange("default_config", e.target.value)}
placeholder='{"placeholder": "입력하세요..."}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
</div>
{/* 유효성 검사 규칙 */}
<div className="space-y-2">
<Label htmlFor="validation_rules"> </Label>
<Textarea
id="validation_rules"
value={jsonStrings.validation_rules}
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
placeholder='{"required": true, "minLength": 1}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
</div>
{/* 기본 스타일 */}
<div className="space-y-2">
<Label htmlFor="default_style"> </Label>
<Textarea
id="default_style"
value={jsonStrings.default_style}
onChange={(e) => handleJsonChange("default_style", e.target.value)}
placeholder='{"width": "100%", "height": "40px"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
</div>
{/* 입력 속성 */}
<div className="space-y-2">
<Label htmlFor="input_properties">HTML </Label>
<Textarea
id="input_properties"
value={jsonStrings.input_properties}
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
placeholder='{"type": "text", "autoComplete": "off"}'
rows={4}
className="font-mono text-xs"
/>
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
{/* 액션 버튼 */}
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isCreating}>
<Save className="mr-2 h-4 w-4" />
{isCreating ? "생성 중..." : "저장"}
</Button>
</div>
{/* 에러 메시지 */}
{createError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,345 +0,0 @@
"use client";
import React, { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
import { useWebTypes } from "@/hooks/admin/useWebTypes";
import Link from "next/link";
export default function WebTypesManagePage() {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("");
const [activeFilter, setActiveFilter] = useState<string>("Y");
const [sortField, setSortField] = useState<string>("sort_order");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
// 웹타입 데이터 조회
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
active: activeFilter || undefined,
search: searchTerm || undefined,
category: categoryFilter || undefined,
});
// 카테고리 목록 생성
const categories = useMemo(() => {
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
return uniqueCategories.sort();
}, [webTypes]);
// 필터링 및 정렬된 데이터
const filteredAndSortedWebTypes = useMemo(() => {
let filtered = [...webTypes];
// 정렬
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof typeof a];
let bValue: any = b[sortField as keyof typeof b];
// 숫자 필드 처리
if (sortField === "sort_order") {
aValue = aValue || 0;
bValue = bValue || 0;
}
// 문자열 필드 처리
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
}
if (typeof bValue === "string") {
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
});
return filtered;
}, [webTypes, sortField, sortDirection]);
// 정렬 변경 핸들러
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 삭제 핸들러
const handleDelete = async (webType: string, typeName: string) => {
try {
await deleteWebType(webType);
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
}
};
// 필터 초기화
const resetFilters = () => {
setSearchTerm("");
setCategoryFilter("");
setActiveFilter("Y");
setSortField("sort_order");
setSortDirection("asc");
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-lg"> ...</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg text-red-600"> .</div>
<Button onClick={() => refetch()} variant="outline">
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
{/* 헤더 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> .</p>
</div>
<Link href="/admin/system-settings/web-types/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 필터 및 검색 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input
placeholder="웹타입명, 설명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 활성화 상태 필터 */}
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
{/* 초기화 버튼 */}
<Button variant="outline" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 결과 통계 */}
<div className="mb-4">
<p className="text-muted-foreground text-sm"> {filteredAndSortedWebTypes.length} .</p>
</div>
{/* 웹타입 목록 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
<div className="flex items-center gap-2">
{sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
<div className="flex items-center gap-2">
{sortField === "web_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
<div className="flex items-center gap-2">
{sortField === "type_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
<div className="flex items-center gap-2">
{sortField === "category" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead></TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
<div className="flex items-center gap-2">
{sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
<div className="flex items-center gap-2">
{sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedWebTypes.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center">
.
</TableCell>
</TableRow>
) : (
filteredAndSortedWebTypes.map((webType) => (
<TableRow key={webType.web_type}>
<TableCell className="font-mono">{webType.sort_order || 0}</TableCell>
<TableCell className="font-mono">{webType.web_type}</TableCell>
<TableCell className="font-medium">
{webType.type_name}
{webType.type_name_eng && (
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{webType.category}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">{webType.description || "-"}</TableCell>
<TableCell>
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
{webType.is_active === "Y" ? "활성화" : "비활성화"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link href={`/admin/system-settings/web-types/${webType.web_type}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/system-settings/web-types/${webType.web_type}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
'{webType.type_name}' ?
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(webType.web_type, webType.type_name)}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{deleteError && (
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-red-600">
: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
</p>
</div>
)}
</div>
);
}

View File

@ -3,7 +3,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react"; import { Mail, Plus, Loader2, RefreshCw, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
getMailAccounts, getMailAccounts,
@ -19,6 +22,7 @@ import MailAccountTable from "@/components/mail/MailAccountTable";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal"; import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailAccountsPage() { export default function MailAccountsPage() {
const router = useRouter();
const [accounts, setAccounts] = useState<MailAccount[]>([]); const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@ -124,43 +128,60 @@ export default function MailAccountsPage() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <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 className="bg-card rounded-lg border p-6 space-y-4">
<div> {/* 브레드크럼브 */}
<h1 className="text-3xl font-bold text-gray-900"> </h1> <nav className="flex items-center gap-2 text-sm">
<p className="mt-2 text-gray-600">SMTP </p> <Link
</div> href="/admin/mail/dashboard"
<div className="flex gap-2"> className="text-muted-foreground hover:text-foreground transition-colors"
<Button
variant="outline"
size="sm"
onClick={loadAccounts}
disabled={loading}
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Link>
</Button> <ChevronRight className="w-4 h-4 text-muted-foreground" />
<Button <span className="text-foreground font-medium"> </span>
className="bg-orange-500 hover:bg-orange-600" </nav>
onClick={handleOpenCreateModal}
> <Separator />
<Plus className="w-4 h-4 mr-2" />
{/* 제목 + 액션 버튼들 */}
</Button> <div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">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
variant="default"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div> </div>
</div> </div>
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 */}
{loading ? ( {loading ? (
<Card className="shadow-sm"> <Card>
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="shadow-sm"> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<MailAccountTable <MailAccountTable
accounts={accounts} accounts={accounts}
@ -174,28 +195,28 @@ export default function MailAccountsPage() {
)} )}
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-lg flex items-center">
<Mail className="w-5 h-5 mr-2 text-orange-500" /> <Mail className="w-5 h-5 mr-2 text-foreground" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-700 mb-4"> <p className="text-foreground mb-4">
💡 SMTP ! 💡 SMTP !
</p> </p>
<ul className="space-y-2 text-sm text-gray-600"> <ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span>Gmail, Naver, SMTP </span> <span>Gmail, Naver, SMTP </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span> </span> <span> </span>
</li> </li>
</ul> </ul>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -12,9 +13,9 @@ import {
TrendingUp, TrendingUp,
Users, Users,
Calendar, Calendar,
Clock ArrowRight
} from "lucide-react"; } from "lucide-react";
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail"; import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
interface DashboardStats { interface DashboardStats {
totalAccounts: number; totalAccounts: number;
@ -39,19 +40,26 @@ export default function MailDashboardPage() {
const loadStats = async () => { const loadStats = async () => {
setLoading(true); setLoading(true);
try { try {
// 계정 수 (apiClient를 통해 토큰 포함)
const accounts = await getMailAccounts(); const accounts = await getMailAccounts();
// 템플릿 수 (apiClient를 통해 토큰 포함)
const templates = await getMailTemplates(); const templates = await getMailTemplates();
const mailStats = await getMailStatistics();
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
let receivedTodayCount = 0;
try {
receivedTodayCount = await getTodayReceivedCount();
} catch (error) {
console.error('수신 메일 수 조회 실패:', error);
// 실패 시 0으로 표시
}
setStats({ setStats({
totalAccounts: accounts.length, totalAccounts: accounts.length,
totalTemplates: templates.length, totalTemplates: templates.length,
sentToday: 0, // TODO: 실제 발송 통계 API 연동 sentToday: mailStats.todayCount,
receivedToday: 0, receivedToday: receivedTodayCount,
sentThisMonth: 0, sentThisMonth: mailStats.thisMonthCount,
successRate: 0, successRate: mailStats.successRate,
}); });
} catch (error) { } catch (error) {
// console.error('통계 로드 실패:', error); // console.error('통계 로드 실패:', error);
@ -71,7 +79,8 @@ export default function MailDashboardPage() {
icon: Users, icon: Users,
color: "blue", color: "blue",
bgColor: "bg-blue-100", bgColor: "bg-blue-100",
iconColor: "text-blue-500", iconColor: "text-blue-600",
href: "/admin/mail/accounts",
}, },
{ {
title: "템플릿 수", title: "템플릿 수",
@ -79,7 +88,8 @@ export default function MailDashboardPage() {
icon: FileText, icon: FileText,
color: "green", color: "green",
bgColor: "bg-green-100", bgColor: "bg-green-100",
iconColor: "text-green-500", iconColor: "text-green-600",
href: "/admin/mail/templates",
}, },
{ {
title: "오늘 발송", title: "오늘 발송",
@ -87,7 +97,8 @@ export default function MailDashboardPage() {
icon: Send, icon: Send,
color: "orange", color: "orange",
bgColor: "bg-orange-100", bgColor: "bg-orange-100",
iconColor: "text-orange-500", iconColor: "text-orange-600",
href: "/admin/mail/sent",
}, },
{ {
title: "오늘 수신", title: "오늘 수신",
@ -95,94 +106,171 @@ export default function MailDashboardPage() {
icon: Inbox, icon: Inbox,
color: "purple", color: "purple",
bgColor: "bg-purple-100", bgColor: "bg-purple-100",
iconColor: "text-purple-500", iconColor: "text-purple-600",
href: "/admin/mail/receive",
},
];
const quickLinks = [
{
title: "계정 관리",
description: "메일 계정 설정",
href: "/admin/mail/accounts",
icon: Users,
color: "blue",
},
{
title: "템플릿 관리",
description: "템플릿 편집",
href: "/admin/mail/templates",
icon: FileText,
color: "green",
},
{
title: "메일 발송",
description: "메일 보내기",
href: "/admin/mail/send",
icon: Send,
color: "orange",
},
{
title: "보낸메일함",
description: "발송 이력 확인",
href: "/admin/mail/sent",
icon: Mail,
color: "indigo",
},
{
title: "수신함",
description: "받은 메일 확인",
href: "/admin/mail/receive",
icon: Inbox,
color: "purple",
}, },
]; ];
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6"> <div className="flex items-center justify-between bg-card rounded-lg border p-8">
<div> <div className="flex items-center gap-4">
<h1 className="text-3xl font-bold text-gray-900"> </h1> <div className="p-4 bg-primary/10 rounded-lg">
<p className="mt-2 text-gray-600"> </p> <Mail className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground mb-1"> </h1>
<p className="text-muted-foreground"> </p>
</div>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="lg"
onClick={loadStats} onClick={loadStats}
disabled={loading} disabled={loading}
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow"> <Link key={index} href={stat.href}>
<CardContent className="p-6"> <Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
<div className="flex items-center justify-between"> <CardContent className="p-6">
<div> <div className="flex items-start justify-between mb-4">
<p className="text-sm text-gray-500 mb-1">{stat.title}</p> <div className="flex-1">
<p className="text-3xl font-bold text-gray-900">{stat.value}</p> <p className="text-sm font-medium text-muted-foreground mb-3">
{stat.title}
</p>
<p className="text-4xl font-bold text-foreground">
{stat.value}
</p>
</div>
<div className="p-4 bg-muted rounded-lg">
<stat.icon className="w-7 h-7 text-muted-foreground" />
</div>
</div> </div>
<div className={`${stat.bgColor} p-3 rounded-lg`}> {/* 진행 바 */}
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} /> <div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000"
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
></div>
</div> </div>
</div> </CardContent>
</CardContent> </Card>
</Card> </Link>
))} ))}
</div> </div>
{/* 이번 달 통계 */} {/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<Card className="shadow-sm"> <Card>
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50"> <CardHeader className="border-b">
<CardTitle className="flex items-center"> <CardTitle className="text-lg flex items-center">
<Calendar className="w-5 h-5 mr-2 text-orange-500" /> <div className="p-2 bg-muted rounded-lg mr-3">
<Calendar className="w-5 h-5 text-foreground" />
</div>
<span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<span className="text-sm text-gray-600"> </span> <span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-lg font-semibold text-gray-900"> <span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} </span>
{stats.sentThisMonth}
</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<span className="text-sm text-gray-600"></span> <span className="text-sm font-medium text-muted-foreground"></span>
<span className="text-lg font-semibold text-green-600"> <span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
{stats.successRate}%
</span>
</div> </div>
<div className="pt-4 border-t"> {/*
<div className="flex items-center text-sm text-gray-500"> <div className="flex items-center justify-between pt-3 border-t">
<TrendingUp className="w-4 h-4 mr-2 text-green-500" /> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
12% <TrendingUp className="w-4 h-4" />
</div> </div>
<span className="text-lg font-bold text-foreground">+12%</span>
</div> </div>
*/}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="shadow-sm"> <Card>
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50"> <CardHeader className="border-b">
<CardTitle className="flex items-center"> <CardTitle className="text-lg flex items-center">
<Clock className="w-5 h-5 mr-2 text-blue-500" /> <div className="p-2 bg-muted rounded-lg mr-3">
<Mail className="w-5 h-5 text-foreground" />
</div>
<span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="space-y-3"> <div className="space-y-4">
<div className="text-center text-gray-500 py-8"> <div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" /> <div className="flex items-center gap-3">
<p className="text-sm"> </p> <div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<span className="text-sm font-bold text-foreground"> </span>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> </span>
</div>
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} </span>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> 릿</span>
</div>
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} </span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -190,93 +278,32 @@ export default function MailDashboardPage() {
</div> </div>
{/* 빠른 액세스 */} {/* 빠른 액세스 */}
<Card className="shadow-sm"> <Card>
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50"> <CardHeader className="border-b">
<CardTitle> </CardTitle> <CardTitle className="text-lg"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<a {quickLinks.map((link, index) => (
href="/admin/mail/accounts" <a
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all" key={index}
> href={link.href}
<Users className="w-8 h-8 text-blue-500 mr-3" /> className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
<div> >
<p className="font-medium text-gray-900"> </p> <div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
<p className="text-sm text-gray-500"> </p> <link.icon className="w-6 h-6 text-muted-foreground" />
</div> </div>
</a> <div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
<a <p className="text-sm text-muted-foreground truncate">{link.description}</p>
href="/admin/mail/templates" </div>
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all" <ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
> </a>
<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> </div>
</CardContent> </CardContent>
</Card> </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>
</div> </div>
); );
} }

View File

@ -15,7 +15,11 @@ import {
Filter, Filter,
SortAsc, SortAsc,
SortDesc, SortDesc,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
ReceivedMail, ReceivedMail,
@ -26,6 +30,7 @@ import {
import MailDetailModal from "@/components/mail/MailDetailModal"; import MailDetailModal from "@/components/mail/MailDetailModal";
export default function MailReceivePage() { export default function MailReceivePage() {
const router = useRouter();
const [accounts, setAccounts] = useState<MailAccount[]>([]); const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(""); const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [mails, setMails] = useState<ReceivedMail[]>([]); const [mails, setMails] = useState<ReceivedMail[]>([]);
@ -197,17 +202,33 @@ export default function MailReceivePage() {
}, [mails, searchTerm, filterStatus, sortBy]); }, [mails, searchTerm, filterStatus, sortBy]);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <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 className="bg-card rounded-lg border p-6 space-y-4">
<div> {/* 브레드크럼브 */}
<h1 className="text-3xl font-bold text-gray-900"> </h1> <nav className="flex items-center gap-2 text-sm">
<p className="mt-2 text-gray-600"> <Link
IMAP으로 href="/admin/mail/dashboard"
</p> className="text-muted-foreground hover:text-foreground transition-colors"
</div> >
<div className="flex gap-2">
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">
IMAP으로
</p>
</div>
<div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -232,20 +253,21 @@ export default function MailReceivePage() {
)} )}
</Button> </Button>
</div>
</div> </div>
</div> </div>
{/* 계정 선택 */} {/* 계정 선택 */}
<Card className="shadow-sm"> <Card className="">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"> <label className="text-sm font-medium text-foreground whitespace-nowrap">
: :
</label> </label>
<select <select
value={selectedAccountId} value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)} 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" className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
> >
<option value=""> </option> <option value=""> </option>
{accounts.map((account) => ( {accounts.map((account) => (
@ -278,7 +300,7 @@ export default function MailReceivePage() {
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
{selectedAccountId && ( {selectedAccountId && (
<Card className="shadow-sm"> <Card className="">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-3"> <div className="flex flex-col md:flex-row gap-3">
{/* 검색 */} {/* 검색 */}
@ -289,17 +311,17 @@ export default function MailReceivePage() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목, 발신자, 내용으로 검색..." 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" className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
/> />
</div> </div>
{/* 필터 */} {/* 필터 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-500" /> <Filter className="w-4 h-4 text-muted-foreground" />
<select <select
value={filterStatus} value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)} 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" className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
> >
<option value="all"></option> <option value="all"></option>
<option value="unread"> </option> <option value="unread"> </option>
@ -311,14 +333,14 @@ export default function MailReceivePage() {
{/* 정렬 */} {/* 정렬 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortBy.includes("desc") ? ( {sortBy.includes("desc") ? (
<SortDesc className="w-4 h-4 text-gray-500" /> <SortDesc className="w-4 h-4 text-muted-foreground" />
) : ( ) : (
<SortAsc className="w-4 h-4 text-gray-500" /> <SortAsc className="w-4 h-4 text-muted-foreground" />
)} )}
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value)} 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" className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
> >
<option value="date-desc"> ()</option> <option value="date-desc"> ()</option>
<option value="date-asc"> ()</option> <option value="date-asc"> ()</option>
@ -330,7 +352,7 @@ export default function MailReceivePage() {
{/* 검색 결과 카운트 */} {/* 검색 결과 카운트 */}
{(searchTerm || filterStatus !== "all") && ( {(searchTerm || filterStatus !== "all") && (
<div className="mt-3 text-sm text-gray-600"> <div className="mt-3 text-sm text-muted-foreground">
{filteredAndSortedMails.length} {filteredAndSortedMails.length}
{searchTerm && ( {searchTerm && (
<span className="ml-2"> <span className="ml-2">
@ -345,17 +367,17 @@ export default function MailReceivePage() {
{/* 메일 목록 */} {/* 메일 목록 */}
{loading ? ( {loading ? (
<Card className="shadow-sm"> <Card className="">
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-gray-600"> ...</span> <span className="ml-3 text-muted-foreground"> ...</span>
</CardContent> </CardContent>
</Card> </Card>
) : filteredAndSortedMails.length === 0 ? ( ) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-white shadow-sm"> <Card className="text-center py-16 bg-card ">
<CardContent className="pt-6"> <CardContent className="pt-6">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" /> <Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500 mb-4"> <p className="text-muted-foreground mb-4">
{!selectedAccountId {!selectedAccountId
? "메일 계정을 선택하세요" ? "메일 계정을 선택하세요"
: searchTerm || filterStatus !== "all" : searchTerm || filterStatus !== "all"
@ -379,7 +401,7 @@ export default function MailReceivePage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="shadow-sm"> <Card className="">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b"> <CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" /> <Inbox className="w-5 h-5 text-orange-500" />
@ -392,7 +414,7 @@ export default function MailReceivePage() {
<div <div
key={mail.id} key={mail.id}
onClick={() => handleMailClick(mail)} onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${ className={`p-4 hover:bg-background transition-colors cursor-pointer ${
!mail.isRead ? "bg-blue-50/30" : "" !mail.isRead ? "bg-blue-50/30" : ""
}`} }`}
> >
@ -410,8 +432,8 @@ export default function MailReceivePage() {
<span <span
className={`text-sm ${ className={`text-sm ${
mail.isRead mail.isRead
? "text-gray-600" ? "text-muted-foreground"
: "text-gray-900 font-semibold" : "text-foreground font-semibold"
}`} }`}
> >
{mail.from} {mail.from}
@ -420,19 +442,19 @@ export default function MailReceivePage() {
{mail.hasAttachments && ( {mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" /> <Paperclip className="w-4 h-4 text-gray-400" />
)} )}
<span className="text-xs text-gray-500"> <span className="text-xs text-muted-foreground">
{formatDate(mail.date)} {formatDate(mail.date)}
</span> </span>
</div> </div>
</div> </div>
<h3 <h3
className={`text-sm mb-1 truncate ${ className={`text-sm mb-1 truncate ${
mail.isRead ? "text-gray-700" : "text-gray-900 font-medium" mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`} }`}
> >
{mail.subject} {mail.subject}
</h3> </h3>
<p className="text-xs text-gray-500 line-clamp-2"> <p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview} {mail.preview}
</p> </p>
</div> </div>
@ -445,7 +467,7 @@ export default function MailReceivePage() {
)} )}
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 shadow-sm"> <Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-lg flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-green-600" /> <CheckCircle className="w-5 h-5 mr-2 text-green-600" />
@ -453,13 +475,13 @@ export default function MailReceivePage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-700 mb-4"> <p className="text-foreground mb-4">
: :
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<p className="font-medium text-gray-800 mb-2">📬 </p> <p className="font-medium text-gray-800 mb-2">📬 </p>
<ul className="space-y-1 text-sm text-gray-600"> <ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="text-green-500 mr-2"></span>
<span>IMAP </span> <span>IMAP </span>
@ -480,7 +502,7 @@ export default function MailReceivePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">📄 </p> <p className="font-medium text-gray-800 mb-2">📄 </p>
<ul className="space-y-1 text-sm text-gray-600"> <ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="text-green-500 mr-2"></span>
<span>HTML </span> <span>HTML </span>
@ -501,7 +523,7 @@ export default function MailReceivePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">🔍 </p> <p className="font-medium text-gray-800 mb-2">🔍 </p>
<ul className="space-y-1 text-sm text-gray-600"> <ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="text-green-500 mr-2"></span>
<span> (//)</span> <span> (//)</span>
@ -522,7 +544,7 @@ export default function MailReceivePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">🔒 </p> <p className="font-medium text-gray-800 mb-2">🔒 </p>
<ul className="space-y-1 text-sm text-gray-600"> <ul className="space-y-1 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="text-green-500 mr-2"></span>
<span>XSS (DOMPurify)</span> <span>XSS (DOMPurify)</span>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,699 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Inbox,
Search,
Filter,
Eye,
Trash2,
RefreshCw,
CheckCircle2,
XCircle,
Mail,
Calendar,
User,
Paperclip,
Loader2,
X,
File,
ChevronRight,
ChevronDown,
ChevronUp,
Send,
AlertCircle,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
SentMailHistory,
getSentMailList,
deleteSentMail,
getMailAccounts,
MailAccount,
getMailStatistics,
} from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast";
export default function SentMailPage() {
const router = useRouter();
const { toast } = useToast();
const [mails, setMails] = useState<SentMailHistory[]>([]);
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMail, setSelectedMail] = useState<SentMailHistory | null>(null);
const [showFilters, setShowFilters] = useState(false);
// 통계
const [stats, setStats] = useState({
totalSent: 0,
successCount: 0,
failedCount: 0,
todayCount: 0,
});
// 필터 및 페이징
const [searchTerm, setSearchTerm] = useState("");
const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'failed'>('all');
const [filterAccountId, setFilterAccountId] = useState<string>('all');
const [sortBy, setSortBy] = useState<'sentAt' | 'subject'>('sentAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
loadAccounts();
loadStats();
}, []);
useEffect(() => {
loadMails();
}, [page, filterStatus, filterAccountId, sortBy, sortOrder]);
const loadAccounts = async () => {
try {
const data = await getMailAccounts();
setAccounts(data);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "계정 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const loadStats = async () => {
try {
const data = await getMailStatistics();
setStats({
totalSent: data.totalSent,
successCount: data.successCount,
failedCount: data.failedCount,
todayCount: data.todayCount,
});
} catch (error: unknown) {
console.error('통계 로드 실패:', error);
}
};
const loadMails = async () => {
try {
setLoading(true);
const result = await getSentMailList({
page,
limit: 20,
searchTerm: searchTerm || undefined,
status: filterStatus,
accountId: filterAccountId !== 'all' ? filterAccountId : undefined,
sortBy,
sortOrder,
});
setMails(result.items);
setTotalPages(result.totalPages);
setTotal(result.total);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "발송 이력 로드 실패",
description: err.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setPage(1);
loadMails();
};
const handleDelete = async (id: string) => {
if (!confirm("이 발송 이력을 삭제하시겠습니까?")) {
return;
}
try {
await deleteSentMail(id);
toast({
title: "삭제 완료",
description: "발송 이력이 삭제되었습니다.",
});
loadMails();
loadStats();
} catch (error: unknown) {
const err = error as Error;
toast({
title: "삭제 실패",
description: err.message,
variant: "destructive",
});
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
};
if (loading && page === 1 && mails.length === 0) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-6 space-y-6 bg-background min-h-screen">
{/* 헤더 */}
<div className="bg-card rounded-lg border p-6 space-y-4">
{/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
</Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium"> </span>
</nav>
<Separator />
{/* 제목 및 빠른 액션 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
<Inbox className="w-8 h-8" />
</h1>
<p className="mt-2 text-muted-foreground"> {total} </p>
</div>
<Button onClick={loadMails} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.totalSent}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<Send className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.successCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<CheckCircle2 className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.failedCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<XCircle className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="text-2xl font-bold text-foreground mt-1">{stats.todayCount}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<Calendar className="w-6 h-6 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 검색 및 필터 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Search className="w-5 h-5" />
<CardTitle></CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="w-4 h-4 mr-2" />
{showFilters ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 기본 검색 */}
<div className="flex gap-2">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목 또는 받는사람으로 검색..."
className="flex-1"
/>
<Button onClick={handleSearch}>
<Search className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 고급 필터 (접기/펼치기) */}
{showFilters && (
<div className="pt-4 border-t space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 상태 필터 */}
<div>
<Label> </Label>
<Select value={filterStatus} onValueChange={(v: any) => {
setFilterStatus(v);
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"> </SelectItem>
<SelectItem value="failed"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 계정 필터 */}
<div>
<Label> </Label>
<Select value={filterAccountId} onValueChange={(v) => {
setFilterAccountId(v);
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{accounts.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
{acc.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 */}
<div>
<Label></Label>
<Select value={`${sortBy}-${sortOrder}`} onValueChange={(v) => {
const [by, order] = v.split('-');
setSortBy(by as 'sentAt' | 'subject');
setSortOrder(order as 'asc' | 'desc');
setPage(1);
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sentAt-desc"></SelectItem>
<SelectItem value="sentAt-asc"></SelectItem>
<SelectItem value="subject-asc"> ()</SelectItem>
<SelectItem value="subject-desc"> ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 필터 초기화 */}
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchTerm("");
setFilterStatus('all');
setFilterAccountId('all');
setSortBy('sentAt');
setSortOrder('desc');
setPage(1);
}}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* 메일 목록 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
({total})
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : mails.length === 0 ? (
<div className="text-center py-12">
<Inbox className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-3">
{mails.map((mail) => (
<div
key={mail.id}
className="p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-4">
{/* 메일 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{/* 상태 배지 */}
{mail.status === 'success' ? (
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
<CheckCircle2 className="w-3 h-3 mr-1" />
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
</Badge>
)}
{/* 첨부파일 */}
{mail.attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="w-3 h-3 mr-1" />
{mail.attachmentCount}
</Badge>
)}
</div>
{/* 제목 */}
<h3 className="font-semibold text-foreground mb-1 truncate">
{mail.subject || "(제목 없음)"}
</h3>
{/* 수신자 및 날짜 */}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{Array.isArray(mail.to) ? mail.to.join(", ") : mail.to}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(mail.sentAt)}
</span>
</div>
{/* 실패 메시지 */}
{mail.status === 'failed' && mail.errorMessage && (
<div className="mt-2 text-sm text-red-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{mail.errorMessage}
</div>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedMail(mail)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(mail.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
{/* 페이징 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t">
<p className="text-sm text-muted-foreground">
{page} / {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* 메일 상세 모달 */}
{selectedMail && (
<Dialog open={!!selectedMail} onOpenChange={() => setSelectedMail(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 상태 */}
<div>
{selectedMail.status === 'success' ? (
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
<CheckCircle2 className="w-4 h-4 mr-1" />
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-4 h-4 mr-1" />
</Badge>
)}
{selectedMail.status === 'failed' && selectedMail.errorMessage && (
<p className="mt-2 text-sm text-red-600">{selectedMail.errorMessage}</p>
)}
</div>
<Separator />
{/* 발신 정보 */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground"> </h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1 font-medium">{selectedMail.from}</span>
</div>
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">
{Array.isArray(selectedMail.to) ? selectedMail.to.join(", ") : selectedMail.to}
</span>
</div>
{selectedMail.cc && selectedMail.cc.length > 0 && (
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{selectedMail.cc.join(", ")}</span>
</div>
)}
{selectedMail.bcc && selectedMail.bcc.length > 0 && (
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{selectedMail.bcc.join(", ")}</span>
</div>
)}
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{formatDate(selectedMail.sentAt)}</span>
</div>
</div>
</div>
<Separator />
{/* 메일 내용 */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground"> </h3>
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<p className="font-medium">{selectedMail.subject || "(제목 없음)"}</p>
</div>
{selectedMail.templateUsed && (
<div>
<p className="text-sm text-muted-foreground mb-2"> 릿</p>
<Badge variant="outline">{selectedMail.templateUsed}</Badge>
</div>
)}
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<div
className="p-4 border rounded-lg bg-muted/30 max-h-96 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: selectedMail.htmlBody || selectedMail.textBody || "" }}
/>
</div>
</div>
{/* 첨부파일 */}
{selectedMail.attachments && selectedMail.attachments.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<h3 className="font-semibold text-foreground flex items-center gap-2">
<Paperclip className="w-4 h-4" />
({selectedMail.attachments.length})
</h3>
<div className="space-y-2">
{selectedMail.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<File className="w-5 h-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{att.filename}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(att.size || 0)}</p>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
{/* 수신 결과 (성공/실패 목록) */}
{selectedMail.acceptedRecipients && selectedMail.acceptedRecipients.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold text-foreground text-sm"> </h3>
<div className="flex flex-wrap gap-2">
{selectedMail.acceptedRecipients.map((email, idx) => (
<Badge key={idx} variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
{email}
</Badge>
))}
</div>
</div>
</>
)}
{selectedMail.rejectedRecipients && selectedMail.rejectedRecipients.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h3 className="font-semibold text-foreground text-sm"> </h3>
<div className="flex flex-wrap gap-2">
{selectedMail.rejectedRecipients.map((email, idx) => (
<Badge key={idx} variant="destructive">
{email}
</Badge>
))}
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@ -3,7 +3,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, FileText, Loader2, RefreshCw, Search } from "lucide-react"; import { Plus, FileText, Loader2, RefreshCw, Search, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailTemplate, MailTemplate,
getMailTemplates, getMailTemplates,
@ -19,6 +22,7 @@ import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal"; import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailTemplatesPage() { export default function MailTemplatesPage() {
const router = useRouter();
const [templates, setTemplates] = useState<MailTemplate[]>([]); const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -128,52 +132,69 @@ export default function MailTemplatesPage() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-background">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <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 className="bg-card rounded-lg border p-6 space-y-4">
<div> {/* 브레드크럼브 */}
<h1 className="text-3xl font-bold text-gray-900"> 릿 </h1> <nav className="flex items-center gap-2 text-sm">
<p className="mt-2 text-gray-600"> 릿 </p> <Link
</div> href="/admin/mail/dashboard"
<div className="flex gap-2"> className="text-muted-foreground hover:text-foreground transition-colors"
<Button
variant="outline"
size="sm"
onClick={loadTemplates}
disabled={loading}
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Link>
</Button> <ChevronRight className="w-4 h-4 text-muted-foreground" />
<Button <span className="text-foreground font-medium">릿 </span>
onClick={handleOpenCreateModal} </nav>
className="bg-orange-500 hover:bg-orange-600"
> <Separator />
<Plus className="w-4 h-4 mr-2" />
릿 {/* 제목 + 액션 버튼들 */}
</Button> <div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"> 릿 </h1>
<p className="mt-2 text-muted-foreground"> 릿 </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
variant="default"
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
릿
</Button>
</div>
</div> </div>
</div> </div>
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<Card className="shadow-sm"> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="템플릿 이름, 제목으로 검색..." 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" className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
/> />
</div> </div>
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)} 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" className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
> >
<option value="all"> </option> <option value="all"> </option>
{categories.map((cat) => ( {categories.map((cat) => (
@ -188,24 +209,24 @@ export default function MailTemplatesPage() {
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 */}
{loading ? ( {loading ? (
<Card className="shadow-sm"> <Card>
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
</CardContent> </CardContent>
</Card> </Card>
) : filteredTemplates.length === 0 ? ( ) : filteredTemplates.length === 0 ? (
<Card className="text-center py-16 bg-white shadow-sm"> <Card className="text-center py-16">
<CardContent className="pt-6"> <CardContent className="pt-6">
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-300" /> <FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
<p className="text-gray-500 mb-4"> <p className="text-muted-foreground mb-4">
{templates.length === 0 {templates.length === 0
? '아직 생성된 템플릿이 없습니다' ? '아직 생성된 템플릿이 없습니다'
: '검색 결과가 없습니다'} : '검색 결과가 없습니다'}
</p> </p>
{templates.length === 0 && ( {templates.length === 0 && (
<Button <Button
variant="default"
onClick={handleOpenCreateModal} onClick={handleOpenCreateModal}
className="bg-orange-500 hover:bg-orange-600"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
릿 릿
@ -229,28 +250,28 @@ export default function MailTemplatesPage() {
)} )}
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-lg flex items-center">
<FileText className="w-5 h-5 mr-2 text-orange-500" /> <FileText className="w-5 h-5 mr-2 text-foreground" />
릿 릿
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-700 mb-4"> <p className="text-foreground mb-4">
💡 릿 ! 💡 릿 !
</p> </p>
<ul className="space-y-2 text-sm text-gray-600"> <ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span>, , , </span> <span>, , , </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-orange-500 mr-2"></span> <span className="text-foreground mr-2"></span>
<span> (: {"{customer_name}"})</span> <span> (: {"{customer_name}"})</span>
</li> </li>
</ul> </ul>

View File

@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
import { PageListPanel } from "@/components/report/designer/PageListPanel";
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
export default function ReportDesignerPage() {
const params = useParams();
const router = useRouter();
const reportId = params.reportId as string;
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const loadReport = async () => {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") {
setIsLoading(false);
return;
}
try {
const response = await reportApi.getReportById(reportId);
if (!response.success) {
toast({
title: "오류",
description: "리포트를 찾을 수 없습니다.",
variant: "destructive",
});
router.push("/admin/report");
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive",
});
router.push("/admin/report");
} finally {
setIsLoading(false);
}
};
if (reportId) {
loadReport();
}
}, [reportId, router, toast]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
return (
<DndProvider backend={HTML5Backend}>
<ReportDesignerProvider reportId={reportId}>
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
{/* 상단 툴바 */}
<ReportDesignerToolbar />
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 페이지 목록 패널 */}
<PageListPanel />
{/* 좌측 패널 (템플릿, 컴포넌트) */}
<ReportDesignerLeftPanel />
{/* 중앙 캔버스 */}
<ReportDesignerCanvas />
{/* 우측 패널 (속성) */}
<ReportDesignerRightPanel />
</div>
</div>
</ReportDesignerProvider>
</DndProvider>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ReportListTable } from "@/components/report/ReportListTable";
import { Plus, Search, RotateCcw } from "lucide-react";
import { useReportList } from "@/hooks/useReportList";
export default function ReportManagementPage() {
const router = useRouter();
const [searchText, setSearchText] = useState("");
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
const handleSearchClick = () => {
handleSearch(searchText);
};
const handleReset = () => {
setSearchText("");
handleSearch("");
};
const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
router.push("/admin/report/designer/new");
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button onClick={handleCreateNew} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 영역 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="flex gap-2">
<Input
placeholder="리포트명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchClick();
}
}}
className="flex-1"
/>
<Button onClick={handleSearchClick} className="gap-2">
<Search className="h-4 w-4" />
</Button>
<Button onClick={handleReset} variant="outline" className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 리포트 목록 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
📋
<span className="text-muted-foreground text-sm font-normal">( {total})</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ReportListTable
reports={reports}
total={total}
page={page}
limit={limit}
isLoading={isLoading}
onPageChange={setPage}
onRefresh={refetch}
/>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,3 +1,6 @@
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@ -76,7 +79,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
/* Z-Index 계층 구조 */ /* Z-Index 계층 구조 */
--z-background: 1; --z-background: 1;
--z-layout: 10; --z-layout: 10;

View File

@ -23,6 +23,9 @@ export const metadata: Metadata = {
description: "제품 수명 주기 관리(PLM) 솔루션", description: "제품 수명 주기 관리(PLM) 솔루션",
keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"], keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"],
authors: [{ name: "WACE" }], authors: [{ name: "WACE" }],
icons: {
icon: "/favicon.ico",
},
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@ -37,10 +40,6 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="ko" className="h-full"> <html lang="ko" className="h-full">
<head>
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#0f172a" />
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}> <body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full"> <div id="root" className="h-full">
<QueryProvider> <QueryProvider>

View File

@ -47,7 +47,7 @@ export default function ConfirmDeleteModal({
{/* 내용 */} {/* 내용 */}
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<p className="text-gray-700">{message}</p> <p className="text-foreground">{message}</p>
{itemName && ( {itemName && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3"> <div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
<p className="text-sm font-medium text-red-800"> <p className="text-sm font-medium text-red-800">
@ -55,7 +55,7 @@ export default function ConfirmDeleteModal({
</p> </p>
</div> </div>
)} )}
<p className="text-sm text-gray-500"> <p className="text-sm text-muted-foreground">
. ? . ?
</p> </p>
</div> </div>

View File

@ -135,7 +135,7 @@ export default function MailAccountModal({
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* 헤더 */} {/* 헤더 */}
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between"> <div className="sticky top-0 bg-gradient-to-r from-primary to-primary px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-white" /> <Mail className="w-6 h-6 text-white" />
<h2 className="text-xl font-bold text-white"> <h2 className="text-xl font-bold text-white">
@ -154,13 +154,13 @@ export default function MailAccountModal({
<form onSubmit={handleSubmit} className="p-6 space-y-6"> <form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Mail className="w-5 h-5 text-orange-500" /> <Mail className="w-5 h-5 text-primary" />
</h3> </h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
* *
</label> </label>
<input <input
@ -169,13 +169,13 @@ export default function MailAccountModal({
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="예: 회사 공식 메일" placeholder="예: 회사 공식 메일"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
* *
</label> </label>
<input <input
@ -184,7 +184,7 @@ export default function MailAccountModal({
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="info@company.com" placeholder="info@company.com"
/> />
</div> </div>
@ -192,14 +192,14 @@ export default function MailAccountModal({
{/* SMTP 설정 */} {/* SMTP 설정 */}
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Server className="w-5 h-5 text-orange-500" /> <Server className="w-5 h-5 text-primary" />
SMTP SMTP
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
SMTP * SMTP *
</label> </label>
<input <input
@ -208,16 +208,16 @@ export default function MailAccountModal({
value={formData.smtpHost} value={formData.smtpHost}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="smtp.gmail.com" placeholder="smtp.gmail.com"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
: smtp.gmail.com, smtp.naver.com, smtp.office365.com : smtp.gmail.com, smtp.naver.com, smtp.office365.com
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
SMTP * SMTP *
</label> </label>
<input <input
@ -226,16 +226,16 @@ export default function MailAccountModal({
value={formData.smtpPort} value={formData.smtpPort}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="587" placeholder="587"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
587 (TLS) 465 (SSL) 587 (TLS) 465 (SSL)
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
</label> </label>
<select <select
@ -247,7 +247,7 @@ export default function MailAccountModal({
smtpSecure: e.target.value === 'true', smtpSecure: e.target.value === 'true',
})) }))
} }
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
> >
<option value="false">TLS ( 587)</option> <option value="false">TLS ( 587)</option>
<option value="true">SSL ( 465)</option> <option value="true">SSL ( 465)</option>
@ -258,13 +258,13 @@ export default function MailAccountModal({
{/* 인증 정보 */} {/* 인증 정보 */}
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Lock className="w-5 h-5 text-orange-500" /> <Lock className="w-5 h-5 text-primary" />
</h3> </h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
* *
</label> </label>
<input <input
@ -273,16 +273,16 @@ export default function MailAccountModal({
value={formData.smtpUsername} value={formData.smtpUsername}
onChange={handleChange} onChange={handleChange}
required required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="info@company.com" placeholder="info@company.com"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
{mode === 'edit' && '(변경 시에만 입력)'} {mode === 'edit' && '(변경 시에만 입력)'}
</label> </label>
<input <input
@ -291,10 +291,10 @@ export default function MailAccountModal({
value={formData.smtpPassword} value={formData.smtpPassword}
onChange={handleChange} onChange={handleChange}
required={mode === 'create'} required={mode === 'create'}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder={mode === 'edit' ? '변경하지 않으려면 비워두세요' : '••••••••'} placeholder={mode === 'edit' ? '변경하지 않으려면 비워두세요' : '••••••••'}
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
Gmail의 Gmail의
</p> </p>
</div> </div>
@ -302,13 +302,13 @@ export default function MailAccountModal({
{/* 발송 제한 */} {/* 발송 제한 */}
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" /> <Zap className="w-5 h-5 text-primary" />
</h3> </h3>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
</label> </label>
<input <input
@ -316,10 +316,10 @@ export default function MailAccountModal({
name="dailyLimit" name="dailyLimit"
value={formData.dailyLimit} value={formData.dailyLimit}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="1000" placeholder="1000"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
(0 = ) (0 = )
</p> </p>
</div> </div>

View File

@ -82,12 +82,12 @@ export default function MailAccountTable({
if (accounts.length === 0) { if (accounts.length === 0) {
return ( return (
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border-gray-300"> <div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border">
<Mail className="w-16 h-16 text-gray-400 mx-auto mb-4" /> <Mail className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground mb-2"> <p className="text-lg font-medium text-muted-foreground mb-2">
</p> </p>
<p className="text-sm text-gray-500"> <p className="text-sm text-muted-foreground">
"새 계정 추가" . "새 계정 추가" .
</p> </p>
</div> </div>
@ -104,22 +104,22 @@ export default function MailAccountTable({
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="계정명, 이메일, 서버로 검색..." placeholder="계정명, 이메일, 서버로 검색..."
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all" className="w-full pl-10 pr-4 py-3 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-all"
/> />
</div> </div>
{/* 테이블 */} {/* 테이블 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border-gray-200"> <thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border">
<tr> <tr>
<th <th
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition" className="px-6 py-4 text-left text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
onClick={() => handleSort('name')} onClick={() => handleSort('name')}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-orange-500" /> <Mail className="w-4 h-4 text-primary" />
{sortField === 'name' && ( {sortField === 'name' && (
<span className="text-xs"> <span className="text-xs">
@ -129,39 +129,39 @@ export default function MailAccountTable({
</div> </div>
</th> </th>
<th <th
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition" className="px-6 py-4 text-left text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
onClick={() => handleSort('email')} onClick={() => handleSort('email')}
> >
</th> </th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700"> <th className="px-6 py-4 text-left text-sm font-semibold text-foreground">
SMTP SMTP
</th> </th>
<th <th
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition" className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
onClick={() => handleSort('status')} onClick={() => handleSort('status')}
> >
</th> </th>
<th <th
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition" className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
onClick={() => handleSort('dailyLimit')} onClick={() => handleSort('dailyLimit')}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Zap className="w-4 h-4 text-orange-500" /> <Zap className="w-4 h-4 text-primary" />
</div> </div>
</th> </th>
<th <th
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition" className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
onClick={() => handleSort('createdAt')} onClick={() => handleSort('createdAt')}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Calendar className="w-4 h-4 text-orange-500" /> <Calendar className="w-4 h-4 text-primary" />
</div> </div>
</th> </th>
<th className="px-6 py-4 text-center text-sm font-semibold text-gray-700"> <th className="px-6 py-4 text-center text-sm font-semibold text-foreground">
</th> </th>
</tr> </tr>
@ -173,7 +173,7 @@ export default function MailAccountTable({
className="hover:bg-orange-50/50 transition-colors" className="hover:bg-orange-50/50 transition-colors"
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="font-medium text-gray-900">{account.name}</div> <div className="font-medium text-foreground">{account.name}</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-sm text-muted-foreground">{account.email}</div> <div className="text-sm text-muted-foreground">{account.email}</div>
@ -192,7 +192,7 @@ export default function MailAccountTable({
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all hover:scale-105 ${ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all hover:scale-105 ${
account.status === 'active' account.status === 'active'
? 'bg-green-100 text-green-700 hover:bg-green-200' ? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-muted-foreground hover:bg-gray-200' : 'bg-muted text-muted-foreground hover:bg-gray-200'
}`} }`}
> >
{account.status === 'active' ? ( {account.status === 'active' ? (
@ -209,7 +209,7 @@ export default function MailAccountTable({
</button> </button>
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-foreground">
{account.dailyLimit > 0 {account.dailyLimit > 0
? account.dailyLimit.toLocaleString() ? account.dailyLimit.toLocaleString()
: '무제한'} : '무제한'}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -17,8 +17,11 @@ import {
Save, Save,
Plus, Plus,
Trash2, Trash2,
Settings Settings,
Upload,
X
} from "lucide-react"; } from "lucide-react";
import { getMailTemplates } from "@/lib/api/mail";
export interface MailComponent { export interface MailComponent {
id: string; id: string;
@ -60,13 +63,43 @@ export default function MailDesigner({
const [templateName, setTemplateName] = useState(""); const [templateName, setTemplateName] = useState("");
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [queries, setQueries] = useState<QueryConfig[]>([]); const [queries, setQueries] = useState<QueryConfig[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 템플릿 데이터 로드 (수정 모드)
useEffect(() => {
if (templateId) {
loadTemplate(templateId);
}
}, [templateId]);
const loadTemplate = async (id: string) => {
try {
setIsLoading(true);
const templates = await getMailTemplates();
const template = templates.find(t => t.id === id);
if (template) {
setTemplateName(template.name);
setSubject(template.subject);
setComponents(template.components || []);
console.log('✅ 템플릿 로드 완료:', {
name: template.name,
components: template.components?.length || 0
});
}
} catch (error) {
console.error('❌ 템플릿 로드 실패:', error);
} finally {
setIsLoading(false);
}
};
// 컴포넌트 타입 정의 // 컴포넌트 타입 정의
const componentTypes = [ const componentTypes = [
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" }, { type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" }, { type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" },
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" }, { type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
{ type: "spacer", icon: Square, label: "여백", color: "bg-gray-100 hover:bg-gray-200" }, { type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-gray-200" },
]; ];
// 컴포넌트 추가 // 컴포넌트 추가
@ -74,11 +107,11 @@ export default function MailDesigner({
const newComponent: MailComponent = { const newComponent: MailComponent = {
id: `comp-${Date.now()}`, id: `comp-${Date.now()}`,
type: type as any, type: type as any,
content: type === "text" ? "<p>텍스트를 입력하세요...</p>" : undefined, content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
text: type === "button" ? "버튼" : undefined, text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
url: type === "button" || type === "image" ? "https://example.com" : undefined, url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=Image" : undefined, src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
height: type === "spacer" ? 20 : undefined, height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
styles: { styles: {
padding: "10px", padding: "10px",
backgroundColor: type === "button" ? "#007bff" : "transparent", backgroundColor: type === "button" ? "#007bff" : "transparent",
@ -140,13 +173,25 @@ export default function MailDesigner({
// 선택된 컴포넌트 가져오기 // 선택된 컴포넌트 가져오기
const selected = components.find(c => c.id === selectedComponent); const selected = components.find(c => c.id === selectedComponent);
// 로딩 중일 때
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-muted/30">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-muted-foreground">릿 ...</p>
</div>
</div>
);
}
return ( return (
<div className="flex h-screen bg-gray-50"> <div className="flex h-screen bg-muted/30">
{/* 왼쪽: 컴포넌트 팔레트 */} {/* 왼쪽: 컴포넌트 팔레트 */}
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto"> <div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
<div> <div>
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center"> <h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Mail className="w-4 h-4 mr-2 text-orange-500" /> <Mail className="w-4 h-4 mr-2 text-primary" />
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
@ -155,7 +200,7 @@ export default function MailDesigner({
key={type} key={type}
onClick={() => addComponent(type)} onClick={() => addComponent(type)}
variant="outline" variant="outline"
className={`w-full justify-start ${color} border-gray-300`} className={`w-full justify-start ${color} border`}
> >
<Icon className="w-4 h-4 mr-2" /> <Icon className="w-4 h-4 mr-2" />
{label} {label}
@ -211,10 +256,10 @@ export default function MailDesigner({
{/* 중앙: 캔버스 */} {/* 중앙: 캔버스 */}
<div className="flex-1 p-8 overflow-y-auto"> <div className="flex-1 p-8 overflow-y-auto">
<Card className="max-w-3xl mx-auto"> <Card className="max-w-3xl mx-auto">
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 border-b"> <CardHeader className="bg-gradient-to-r from-muted to-muted border-b">
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span> </span> <span> </span>
<span className="text-sm text-gray-500 font-normal"> <span className="text-sm text-muted-foreground font-normal">
{components.length} {components.length}
</span> </span>
</CardTitle> </CardTitle>
@ -222,8 +267,8 @@ export default function MailDesigner({
<CardContent className="p-0"> <CardContent className="p-0">
{/* 제목 영역 */} {/* 제목 영역 */}
{subject && ( {subject && (
<div className="p-6 bg-gray-50 border-b"> <div className="p-6 bg-muted/30 border-b">
<p className="text-sm text-gray-500">:</p> <p className="text-sm text-muted-foreground">:</p>
<p className="font-semibold text-lg">{subject}</p> <p className="font-semibold text-lg">{subject}</p>
</div> </div>
)} )}
@ -292,8 +337,8 @@ export default function MailDesigner({
{selected ? ( {selected ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-700 flex items-center"> <h3 className="text-sm font-semibold text-foreground flex items-center">
<Settings className="w-4 h-4 mr-2 text-orange-500" /> <Settings className="w-4 h-4 mr-2 text-primary" />
</h3> </h3>
<Button <Button
@ -307,84 +352,234 @@ export default function MailDesigner({
{/* 텍스트 컴포넌트 */} {/* 텍스트 컴포넌트 */}
{selected.type === "text" && ( {selected.type === "text" && (
<div> <div className="space-y-4">
<Label className="text-xs"> (HTML)</Label> <div>
<Textarea <Label className="text-sm font-medium text-foreground flex items-center gap-2">
value={selected.content || ""}
onChange={(e) => </Label>
updateComponent(selected.id, { content: e.target.value }) <p className="text-xs text-muted-foreground mt-1 mb-2">
}
rows={8} </p>
className="mt-1 font-mono text-xs" <Textarea
/> value={(() => {
// 🎯 HTML 태그 자동 제거 (비개발자 친화적)
const content = selected.content || "";
const tmp = document.createElement("DIV");
tmp.innerHTML = content;
return tmp.textContent || tmp.innerText || "";
})()}
onChange={(e) =>
updateComponent(selected.id, { content: e.target.value })
}
onFocus={(e) => {
// 🎯 클릭 시 placeholder 같은 텍스트 자동 제거
const currentValue = e.target.value.trim();
if (currentValue === '텍스트를 입력하세요' ||
currentValue === '텍스트를 입력하세요...') {
e.target.value = '';
updateComponent(selected.id, { content: '' });
}
}}
rows={8}
className="mt-1"
placeholder="예) 안녕하세요! 특별한 소식을 전해드립니다..."
/>
</div>
</div> </div>
)} )}
{/* 버튼 컴포넌트 */} {/* 버튼 컴포넌트 */}
{selected.type === "button" && ( {selected.type === "button" && (
<> <div className="space-y-4">
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-sm font-medium text-foreground flex items-center gap-2">
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
</p>
<Input <Input
value={selected.text || ""} value={selected.text || ""}
onChange={(e) => onChange={(e) =>
updateComponent(selected.id, { text: e.target.value }) updateComponent(selected.id, { text: e.target.value })
} }
className="mt-1" className="mt-1"
placeholder="예) 자세히 보기, 지금 시작하기"
/> />
</div> </div>
<div> <div>
<Label className="text-xs"> URL</Label> <Label className="text-sm font-medium text-foreground flex items-center gap-2">
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
</p>
<Input <Input
value={selected.url || ""} value={selected.url || ""}
onChange={(e) => onChange={(e) =>
updateComponent(selected.id, { url: e.target.value }) updateComponent(selected.id, { url: e.target.value })
} }
className="mt-1" className="mt-1"
placeholder="예) https://www.example.com"
/> />
</div> </div>
<div> <div>
<Label className="text-xs"></Label> <Label className="text-sm font-medium text-foreground flex items-center gap-2">
<Input
type="color" </Label>
value={selected.styles?.backgroundColor || "#007bff"} <p className="text-xs text-muted-foreground mt-1 mb-2">
onChange={(e) =>
updateComponent(selected.id, { </p>
styles: { ...selected.styles, backgroundColor: e.target.value }, <div className="flex items-center gap-3">
}) <Input
} type="color"
className="mt-1" value={selected.styles?.backgroundColor || "#007bff"}
/> onChange={(e) =>
updateComponent(selected.id, {
styles: { ...selected.styles, backgroundColor: e.target.value },
})
}
className="w-16 h-10 cursor-pointer"
/>
<span className="text-sm text-muted-foreground">
{selected.styles?.backgroundColor || "#007bff"}
</span>
</div>
</div> </div>
</> </div>
)} )}
{/* 이미지 컴포넌트 */} {/* 이미지 컴포넌트 */}
{selected.type === "image" && ( {selected.type === "image" && (
<div> <div className="space-y-4">
<Label className="text-xs"> URL</Label> <div>
<Input <Label className="text-sm font-medium text-foreground flex items-center gap-2">
value={selected.src || ""}
onChange={(e) => </Label>
updateComponent(selected.id, { src: e.target.value }) <p className="text-xs text-muted-foreground mt-1 mb-2">
}
className="mt-1" </p>
/>
{/* 파일 업로드 버튼 */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e: any) => {
const file = e.target?.files?.[0];
if (file) {
// 파일을 Base64로 변환
const reader = new FileReader();
reader.onload = (event) => {
updateComponent(selected.id, {
src: event.target?.result as string
});
};
reader.readAsDataURL(file);
}
};
input.click();
}}
>
<Upload className="w-4 h-4 mr-2" />
</Button>
{/* 구분선 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border"></div>
</div>
<div className="relative flex justify-center text-xs">
<span className="px-2 bg-white text-muted-foreground"></span>
</div>
</div>
{/* URL 입력 */}
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
</Label>
<Input
value={selected.src || ""}
onChange={(e) =>
updateComponent(selected.id, { src: e.target.value })
}
className="text-sm"
placeholder="예) https://example.com/image.jpg"
/>
</div>
</div>
</div>
{/* 미리보기 */}
{selected.src && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">:</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => updateComponent(selected.id, { src: "" })}
className="h-6 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg p-2 bg-muted/30">
<img
src={selected.src}
alt="미리보기"
className="w-full rounded"
onError={(e) => {
(e.target as HTMLImageElement).src = 'https://placehold.co/600x200?text=이미지+로드+실패';
}}
/>
</div>
</div>
)}
</div> </div>
)} )}
{/* 여백 컴포넌트 */} {/* 여백 컴포넌트 */}
{selected.type === "spacer" && ( {selected.type === "spacer" && (
<div> <div className="space-y-4">
<Label className="text-xs"> (px)</Label> <div>
<Input <Label className="text-sm font-medium text-foreground flex items-center gap-2">
type="number"
value={selected.height || 20} </Label>
onChange={(e) => <p className="text-xs text-muted-foreground mt-1 mb-2">
updateComponent(selected.id, { height: parseInt(e.target.value) })
} </p>
className="mt-1" <div className="flex items-center gap-3">
/> <Input
type="number"
value={selected.height || 20}
onChange={(e) =>
updateComponent(selected.id, { height: parseInt(e.target.value) || 20 })
}
className="w-24"
min="0"
max="200"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-xs text-blue-800">
<strong>:</strong><br/>
간격: 10~20 <br/>
간격: 30~50 <br/>
간격: 60~100
</p>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -38,12 +38,17 @@ export default function MailDetailModal({
const [mail, setMail] = useState<MailDetail | null>(null); const [mail, setMail] = useState<MailDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showHtml, setShowHtml] = useState(true); // HTML/텍스트 토글 const [imageBlobUrls, setImageBlobUrls] = useState<{ [key: number]: string }>({});
useEffect(() => { useEffect(() => {
if (isOpen && mailId) { if (isOpen && mailId) {
loadMailDetail(); loadMailDetail();
} }
// 컴포넌트 언마운트 시 Blob URL 정리
return () => {
Object.values(imageBlobUrls).forEach((url) => URL.revokeObjectURL(url));
};
}, [isOpen, mailId]); }, [isOpen, mailId]);
const loadMailDetail = async () => { const loadMailDetail = async () => {
@ -67,6 +72,15 @@ export default function MailDetailModal({
await markMailAsRead(accountId, seqno); await markMailAsRead(accountId, seqno);
onMailRead?.(); // 목록 갱신 onMailRead?.(); // 목록 갱신
} }
// 이미지 첨부파일 자동 로드
if (mailDetail.attachments) {
mailDetail.attachments.forEach((attachment, index) => {
if (attachment.contentType?.startsWith('image/')) {
loadImageAttachment(index);
}
});
}
} catch (err) { } catch (err) {
console.error("메일 상세 조회 실패:", err); console.error("메일 상세 조회 실패:", err);
setError( setError(
@ -128,20 +142,77 @@ export default function MailDetailModal({
}); });
}; };
const loadImageAttachment = async (index: number) => {
try {
console.log(`🖼️ 이미지 로드 시작 - index: ${index}`);
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
const token = localStorage.getItem("authToken");
console.log(`🔑 토큰 확인: ${token ? '있음' : '없음'}`);
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
const backendUrl = process.env.NODE_ENV === 'production'
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
console.log(`📍 요청 URL: ${backendUrl}`);
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
console.log(`📡 응답 상태: ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
console.log(`✅ Blob URL 생성 완료: ${blobUrl}`);
setImageBlobUrls((prev) => ({ ...prev, [index]: blobUrl }));
} catch (err) {
console.error(`❌ 이미지 로드 실패 (index ${index}):`, err);
}
};
const handleDownloadAttachment = async (index: number, filename: string) => { const handleDownloadAttachment = async (index: number, filename: string) => {
try { try {
const seqno = parseInt(mailId.split("-").pop() || "0", 10); const seqno = parseInt(mailId.split("-").pop() || "0", 10);
const token = localStorage.getItem("authToken");
// 다운로드 URL // 🔧 임시: 백엔드 직접 호출 (프록시 우회)
const downloadUrl = `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`; const backendUrl = process.env.NODE_ENV === 'production'
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// 다운로드 트리거 // 다운로드 트리거
const link = document.createElement('a'); const link = document.createElement('a');
link.href = downloadUrl; link.href = url;
link.download = filename; link.download = filename;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
// Blob URL 정리
URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error('첨부파일 다운로드 실패:', err); console.error('첨부파일 다운로드 실패:', err);
alert('첨부파일 다운로드에 실패했습니다.'); alert('첨부파일 다운로드에 실패했습니다.');
@ -152,22 +223,14 @@ export default function MailDetailModal({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between pr-6"> <DialogTitle className="text-xl font-bold truncate">
<span className="text-xl font-bold truncate"> </span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="absolute right-4 top-4"
>
<X className="w-4 h-4" />
</Button>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
<div className="flex justify-center items-center py-16"> <div className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground"> ...</span> <span className="ml-3 text-muted-foreground"> ...</span>
</div> </div>
) : error ? ( ) : error ? (
@ -182,27 +245,27 @@ export default function MailDetailModal({
<div className="flex-1 overflow-y-auto space-y-4"> <div className="flex-1 overflow-y-auto space-y-4">
{/* 메일 헤더 */} {/* 메일 헤더 */}
<div className="border-b pb-4 space-y-2"> <div className="border-b pb-4 space-y-2">
<h2 className="text-2xl font-bold text-gray-900"> <h2 className="text-2xl font-bold text-foreground">
{mail.subject} {mail.subject}
</h2> </h2>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div> <div>
<span className="font-medium text-gray-700">:</span>{" "} <span className="font-medium text-foreground">:</span>{" "}
<span className="text-gray-900">{mail.from}</span> <span className="text-foreground">{mail.from}</span>
</div> </div>
<div> <div>
<span className="font-medium text-gray-700">:</span>{" "} <span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground">{mail.to}</span> <span className="text-muted-foreground">{mail.to}</span>
</div> </div>
{mail.cc && ( {mail.cc && (
<div> <div>
<span className="font-medium text-gray-700">:</span>{" "} <span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground">{mail.cc}</span> <span className="text-muted-foreground">{mail.cc}</span>
</div> </div>
)} )}
<div> <div>
<span className="font-medium text-gray-700">:</span>{" "} <span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{formatDate(mail.date)} {formatDate(mail.date)}
</span> </span>
@ -223,81 +286,94 @@ export default function MailDetailModal({
{/* 첨부파일 */} {/* 첨부파일 */}
{mail.attachments && mail.attachments.length > 0 && ( {mail.attachments && mail.attachments.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-muted-foreground" /> <Paperclip className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-gray-700"> <span className="font-medium text-foreground">
({mail.attachments.length}) ({mail.attachments.length})
</span> </span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{mail.attachments.map((attachment, index) => ( {mail.attachments.map((attachment, index) => {
<div const isImage = attachment.contentType?.startsWith('image/');
key={index}
className="flex items-center justify-between bg-white rounded px-3 py-2 border hover:border-orange-300 transition-colors" return (
> <div key={index}>
<div className="flex items-center gap-2"> {/* 첨부파일 정보 */}
<Paperclip className="w-4 h-4 text-gray-400" /> <div className="flex items-center justify-between bg-card rounded px-3 py-2 border hover:border-primary/30 transition-colors">
<span className="text-sm text-gray-900"> <div className="flex items-center gap-2">
{attachment.filename} <Paperclip className="w-4 h-4 text-muted-foreground" />
</span> <span className="text-sm text-foreground">
<Badge variant="secondary" className="text-xs"> {attachment.filename}
{formatFileSize(attachment.size)} </span>
</Badge> <Badge variant="secondary" className="text-xs">
{formatFileSize(attachment.size)}
</Badge>
{isImage && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadAttachment(index, attachment.filename)}
className="hover:bg-accent"
>
</Button>
</div>
{/* 이미지 미리보기 */}
{isImage && (
<div className="mt-2 border rounded-lg overflow-hidden bg-card p-4">
{imageBlobUrls[index] ? (
<img
src={imageBlobUrls[index]}
alt={attachment.filename}
className="max-w-full h-auto max-h-96 mx-auto object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mb-2" />
<p className="text-sm"> ...</p>
<Button
variant="link"
size="sm"
onClick={() => loadImageAttachment(index)}
className="mt-2"
>
</Button>
</div>
)}
</div>
)}
</div> </div>
<Button );
variant="ghost" })}
size="sm"
onClick={() => handleDownloadAttachment(index, attachment.filename)}
className="hover:bg-orange-50 hover:text-orange-600"
>
</Button>
</div>
))}
</div> </div>
</div> </div>
)} )}
{/* HTML/텍스트 토글 */}
{mail.htmlBody && mail.textBody && (
<div className="flex gap-2">
<Button
variant={showHtml ? "default" : "outline"}
size="sm"
onClick={() => setShowHtml(true)}
className={
showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
}
>
HTML
</Button>
<Button
variant={!showHtml ? "default" : "outline"}
size="sm"
onClick={() => setShowHtml(false)}
className={
!showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
}
>
</Button>
</div>
)}
{/* 메일 본문 */} {/* 메일 본문 */}
<div className="border rounded-lg p-6 bg-white min-h-[300px]"> <div className="border rounded-lg p-6 bg-card min-h-[300px]">
{showHtml && mail.htmlBody ? ( {mail.htmlBody ? (
<div <div
className="prose max-w-none" className="prose max-w-none"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: sanitizeHtml(mail.htmlBody), __html: sanitizeHtml(mail.htmlBody),
}} }}
/> />
) : ( ) : mail.textBody ? (
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-800"> <pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
{mail.textBody || "본문 내용이 없습니다."} {mail.textBody}
</pre> </pre>
) : (
<p className="text-muted-foreground text-center py-8">
.
</p>
)} )}
</div> </div>
</div> </div>

View File

@ -34,22 +34,22 @@ export default function MailTemplateCard({
promotion: 'bg-purple-100 text-purple-700 border-purple-300', promotion: 'bg-purple-100 text-purple-700 border-purple-300',
notification: 'bg-green-100 text-green-700 border-green-300', notification: 'bg-green-100 text-green-700 border-green-300',
newsletter: 'bg-orange-100 text-orange-700 border-orange-300', newsletter: 'bg-orange-100 text-orange-700 border-orange-300',
system: 'bg-gray-100 text-gray-700 border-gray-300', system: 'bg-muted text-foreground border',
}; };
return colors[category || ''] || 'bg-gray-100 text-gray-700 border-gray-300'; return colors[category || ''] || 'bg-muted text-foreground border';
}; };
return ( return (
<div className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden"> <div className="group bg-white rounded-xl border border shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden">
{/* 헤더 */} {/* 헤더 */}
<div className="bg-gradient-to-r from-orange-50 to-amber-50 p-4 border-b border-gray-200"> <div className="bg-gradient-to-r from-muted to-muted p-4 border-b border">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1"> <div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-white rounded-lg shadow-sm"> <div className="p-2 bg-white rounded-lg shadow-sm">
<Mail className="w-5 h-5 text-orange-500" /> <Mail className="w-5 h-5 text-primary" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate"> <h3 className="font-semibold text-foreground truncate">
{template.name} {template.name}
</h3> </h3>
<p className="text-sm text-muted-foreground truncate mt-1"> <p className="text-sm text-muted-foreground truncate mt-1">
@ -71,8 +71,8 @@ export default function MailTemplateCard({
{/* 본문 미리보기 */} {/* 본문 미리보기 */}
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200 min-h-[100px]"> <div className="bg-muted/30 rounded-lg p-3 border border min-h-[100px]">
<p className="text-xs text-gray-500 mb-2"> {template.components.length}</p> <p className="text-xs text-muted-foreground mb-2"> {template.components.length}</p>
<div className="space-y-1"> <div className="space-y-1">
{template.components.slice(0, 3).map((component, idx) => ( {template.components.slice(0, 3).map((component, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs text-muted-foreground"> <div key={idx} className="flex items-center gap-2 text-xs text-muted-foreground">
@ -94,7 +94,7 @@ export default function MailTemplateCard({
</div> </div>
{/* 메타 정보 */} {/* 메타 정보 */}
<div className="flex items-center gap-3 text-xs text-gray-500 pt-2 border-t"> <div className="flex items-center gap-3 text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" /> <Calendar className="w-3.5 h-3.5" />
<span>{formatDate(template.createdAt)}</span> <span>{formatDate(template.createdAt)}</span>

View File

@ -47,7 +47,7 @@ export default function MailTemplateEditorModal({
return ( return (
<div className="fixed inset-0 z-50 bg-white"> <div className="fixed inset-0 z-50 bg-white">
{/* 헤더 */} {/* 헤더 */}
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between shadow-lg z-10"> <div className="sticky top-0 bg-gradient-to-r from-primary to-primary px-6 py-4 flex items-center justify-between shadow-lg z-10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-white"> <h2 className="text-xl font-bold text-white">
{mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'} {mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'}

View File

@ -32,23 +32,23 @@ export default function MailTemplatePreviewModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div <div
className={`bg-white rounded-xl shadow-2xl overflow-hidden transition-all ${ className={`bg-card rounded-xl shadow-2xl overflow-hidden transition-all border ${
isFullscreen ? 'w-full h-full' : 'max-w-6xl w-full max-h-[90vh]' isFullscreen ? 'w-full h-full' : 'max-w-6xl w-full max-h-[90vh]'
}`} }`}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between z-10"> <div className="sticky top-0 bg-muted border-b px-6 py-4 flex items-center justify-between z-10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Eye className="w-6 h-6 text-white" /> <Eye className="w-6 h-6 text-foreground" />
<div> <div>
<h2 className="text-xl font-bold text-white">{template.name}</h2> <h2 className="text-xl font-bold text-foreground">{template.name}</h2>
<p className="text-sm text-orange-100">{template.subject}</p> <p className="text-sm text-muted-foreground">{template.subject}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setViewMode(viewMode === 'preview' ? 'code' : 'preview')} onClick={() => setViewMode(viewMode === 'preview' ? 'code' : 'preview')}
className="text-white hover:bg-white/20 rounded-lg px-3 py-2 transition flex items-center gap-2" className="text-foreground hover:bg-background rounded-lg px-3 py-2 transition flex items-center gap-2"
> >
{viewMode === 'preview' ? ( {viewMode === 'preview' ? (
<> <>
@ -64,7 +64,7 @@ export default function MailTemplatePreviewModal({
</button> </button>
<button <button
onClick={() => setIsFullscreen(!isFullscreen)} onClick={() => setIsFullscreen(!isFullscreen)}
className="text-white hover:bg-white/20 rounded-lg p-2 transition" className="text-foreground hover:bg-background rounded-lg p-2 transition"
> >
{isFullscreen ? ( {isFullscreen ? (
<Minimize2 className="w-5 h-5" /> <Minimize2 className="w-5 h-5" />
@ -74,7 +74,7 @@ export default function MailTemplatePreviewModal({
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="text-white hover:bg-white/20 rounded-lg p-2 transition" className="text-foreground hover:bg-background rounded-lg p-2 transition"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@ -85,15 +85,15 @@ export default function MailTemplatePreviewModal({
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
{/* 왼쪽: 변수 입력 (변수가 있을 때만) */} {/* 왼쪽: 변수 입력 (변수가 있을 때만) */}
{templateVariables.length > 0 && ( {templateVariables.length > 0 && (
<div className="w-80 bg-gray-50 border-r border-gray-200 p-6 overflow-y-auto"> <div className="w-80 bg-muted/30 border-r p-6 overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-orange-500" /> <Mail className="w-5 h-5 text-foreground" />
릿 릿
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{templateVariables.map((variable) => ( {templateVariables.map((variable) => (
<div key={variable}> <div key={variable}>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-foreground mb-1">
{variable} {variable}
</label> </label>
<input <input
@ -101,13 +101,13 @@ export default function MailTemplatePreviewModal({
value={variables[variable] || ''} value={variables[variable] || ''}
onChange={(e) => handleVariableChange(variable, e.target.value)} onChange={(e) => handleVariableChange(variable, e.target.value)}
placeholder={`{${variable}}`} placeholder={`{${variable}}`}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
/> />
</div> </div>
))} ))}
</div> </div>
<div className="mt-6 p-4 bg-accent border border-primary/20 rounded-lg"> <div className="mt-6 p-4 bg-muted border rounded-lg">
<p className="text-xs text-blue-800"> <p className="text-xs text-muted-foreground">
💡 . 💡 .
</p> </p>
</div> </div>
@ -117,21 +117,21 @@ export default function MailTemplatePreviewModal({
{/* 오른쪽: 미리보기 또는 코드 */} {/* 오른쪽: 미리보기 또는 코드 */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{viewMode === 'preview' ? ( {viewMode === 'preview' ? (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden"> <div className="bg-card border rounded-lg overflow-hidden">
{/* 이메일 헤더 시뮬레이션 */} {/* 이메일 헤더 시뮬레이션 */}
<div className="bg-gray-100 px-6 py-4 border-b border-gray-200"> <div className="bg-muted px-6 py-4 border-b">
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex"> <div className="flex">
<span className="font-semibold text-muted-foreground w-20">:</span> <span className="font-semibold text-muted-foreground w-20">:</span>
<span className="text-gray-900">{template.subject}</span> <span className="text-foreground">{template.subject}</span>
</div> </div>
<div className="flex"> <div className="flex">
<span className="font-semibold text-muted-foreground w-20">:</span> <span className="font-semibold text-muted-foreground w-20">:</span>
<span className="text-gray-700">your-email@company.com</span> <span className="text-foreground">your-email@company.com</span>
</div> </div>
<div className="flex"> <div className="flex">
<span className="font-semibold text-muted-foreground w-20">:</span> <span className="font-semibold text-muted-foreground w-20">:</span>
<span className="text-gray-700">recipient@example.com</span> <span className="text-foreground">recipient@example.com</span>
</div> </div>
</div> </div>
</div> </div>
@ -143,15 +143,15 @@ export default function MailTemplatePreviewModal({
/> />
</div> </div>
) : ( ) : (
<div className="bg-gray-900 text-gray-100 p-6 rounded-lg font-mono text-sm overflow-x-auto"> <div className="bg-muted/50 border p-6 rounded-lg font-mono text-sm overflow-x-auto">
<pre className="whitespace-pre-wrap break-words">{renderedHtml}</pre> <pre className="whitespace-pre-wrap break-words text-foreground">{renderedHtml}</pre>
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 flex justify-end gap-3"> <div className="sticky bottom-0 bg-muted/30 border-t px-6 py-4 flex justify-end gap-3">
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
</Button> </Button>

View File

@ -0,0 +1,228 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { CreateReportRequest, ReportTemplate } from "@/types/report";
interface ReportCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
const [formData, setFormData] = useState<CreateReportRequest>({
reportNameKor: "",
reportNameEng: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const { toast } = useToast();
// 템플릿 목록 불러오기
useEffect(() => {
if (isOpen) {
fetchTemplates();
}
}, [isOpen]);
const fetchTemplates = async () => {
setIsLoadingTemplates(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setTemplates([...response.data.system, ...response.data.custom]);
}
} catch (error: any) {
toast({
title: "오류",
description: "템플릿 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoadingTemplates(false);
}
};
const handleSubmit = async () => {
// 유효성 검증
if (!formData.reportNameKor.trim()) {
toast({
title: "입력 오류",
description: "리포트명(한글)을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!formData.reportType) {
toast({
title: "입력 오류",
description: "리포트 타입을 선택해주세요.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await reportApi.createReport(formData);
if (response.success) {
toast({
title: "성공",
description: "리포트가 생성되었습니다.",
});
handleClose();
onSuccess();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 생성에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setFormData({
reportNameKor: "",
reportNameEng: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> . .</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 리포트명 (한글) */}
<div className="space-y-2">
<Label htmlFor="reportNameKor">
() <span className="text-destructive">*</span>
</Label>
<Input
id="reportNameKor"
placeholder="예: 발주서"
value={formData.reportNameKor}
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
/>
</div>
{/* 리포트명 (영문) */}
<div className="space-y-2">
<Label htmlFor="reportNameEng"> ()</Label>
<Input
id="reportNameEng"
placeholder="예: Purchase Order"
value={formData.reportNameEng}
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
/>
</div>
{/* 템플릿 선택 */}
<div className="space-y-2">
<Label htmlFor="templateId">릿</Label>
<Select
value={formData.templateId || "none"}
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
disabled={isLoadingTemplates}
>
<SelectTrigger>
<SelectValue placeholder="템플릿 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">릿 </SelectItem>
{templates.map((template) => (
<SelectItem key={template.template_id} value={template.template_id}>
{template.template_name_kor}
{template.is_system === "Y" && " (시스템)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 리포트 타입 */}
<div className="space-y-2">
<Label htmlFor="reportType">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.reportType}
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
>
<SelectTrigger>
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ORDER"></SelectItem>
<SelectItem value="INVOICE"></SelectItem>
<SelectItem value="STATEMENT"></SelectItem>
<SelectItem value="RECEIPT"></SelectItem>
<SelectItem value="BASIC"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
placeholder="리포트에 대한 설명을 입력하세요"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,250 @@
"use client";
import { useState } from "react";
import { ReportMaster } from "@/types/report";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
interface ReportListTableProps {
reports: ReportMaster[];
total: number;
page: number;
limit: number;
isLoading: boolean;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function ReportListTable({
reports,
total,
page,
limit,
isLoading,
onPageChange,
onRefresh,
}: ReportListTableProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const { toast } = useToast();
const router = useRouter();
const totalPages = Math.ceil(total / limit);
// 수정
const handleEdit = (reportId: string) => {
router.push(`/admin/report/designer/${reportId}`);
};
// 복사
const handleCopy = async (reportId: string) => {
setIsCopying(true);
try {
const response = await reportApi.copyReport(reportId);
if (response.success) {
toast({
title: "성공",
description: "리포트가 복사되었습니다.",
});
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 복사에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsCopying(false);
}
};
// 삭제 확인
const handleDeleteClick = (reportId: string) => {
setDeleteTarget(reportId);
};
// 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const response = await reportApi.deleteReport(deleteTarget);
if (response.success) {
toast({
title: "성공",
description: "리포트가 삭제되었습니다.",
});
setDeleteTarget(null);
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
// 날짜 포맷
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy-MM-dd");
} catch {
return dateString;
}
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (reports.length === 0) {
return (
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
<p> .</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<TableRow key={report.report_id}>
<TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell>
<div>
<div className="font-medium">{report.report_name_kor}</div>
{report.report_name_eng && (
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>{report.created_by || "-"}</TableCell>
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(report.report_id)}
className="gap-1"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(report.report_id)}
disabled={isCopying}
className="gap-1"
>
<Copy className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick(report.report_id)}
className="gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4">
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
</Button>
<span className="text-muted-foreground text-sm">
{page} / {totalPages}
</span>
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
</Button>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,618 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { ComponentConfig } from "@/types/report";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { getFullImageUrl } from "@/lib/api/client";
interface CanvasComponentProps {
component: ComponentConfig;
}
export function CanvasComponent({ component }: CanvasComponentProps) {
const {
components,
selectedComponentId,
selectedComponentIds,
selectComponent,
updateComponent,
getQueryResult,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
margins,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const componentRef = useRef<HTMLDivElement>(null);
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
const isGrouped = !!component.groupId;
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
// 잠긴 컴포넌트는 드래그 불가
if (isLocked) {
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
selectComponent(component.id, isMultiSelect);
return;
}
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
if (isGrouped && !isMultiSelect) {
const groupMembers = components.filter((c) => c.groupId === component.groupId);
const groupMemberIds = groupMembers.map((c) => c.id);
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
selectComponent(groupMemberIds[0], false);
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
} else {
selectComponent(component.id, isMultiSelect);
}
setIsDragging(true);
setDragStart({
x: e.clientX - component.x,
y: e.clientY - component.y,
});
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
// 잠긴 컴포넌트는 리사이즈 불가
if (isLocked) {
e.stopPropagation();
return;
}
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: component.width,
height: component.height,
});
};
// 마우스 이동 핸들러 (전역)
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = Math.max(0, e.clientX - dragStart.x);
const newY = Math.max(0, e.clientY - dragStart.y);
// 여백을 px로 변환 (1mm ≈ 3.7795px)
const marginTopPx = margins.top * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
const marginLeftPx = margins.left * 3.7795;
const marginRightPx = margins.right * 3.7795;
// 캔버스 경계 체크 (mm를 px로 변환)
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 컴포넌트가 여백 안에 있도록 제한
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - component.width;
const maxY = canvasHeightPx - marginBottomPx - component.height;
const boundedX = Math.min(Math.max(minX, newX), maxX);
const boundedY = Math.min(Math.max(minY, newY), maxY);
const snappedX = snapValueToGrid(boundedX);
const snappedY = snapValueToGrid(boundedY);
// 정렬 가이드라인 계산
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
// 이동 거리 계산
const deltaX = snappedX - component.x;
const deltaY = snappedY - component.y;
// 현재 컴포넌트 이동
updateComponent(component.id, {
x: snappedX,
y: snappedY,
});
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
if (isGrouped) {
components.forEach((c) => {
if (c.groupId === component.groupId && c.id !== component.id) {
const newGroupX = c.x + deltaX;
const newGroupY = c.y + deltaY;
// 그룹 컴포넌트도 경계 체크
const groupMaxX = canvasWidthPx - c.width;
const groupMaxY = canvasHeightPx - c.height;
updateComponent(c.id, {
x: Math.min(Math.max(0, newGroupX), groupMaxX),
y: Math.min(Math.max(0, newGroupY), groupMaxY),
});
}
});
}
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
const newWidth = Math.max(50, resizeStart.width + deltaX);
const newHeight = Math.max(30, resizeStart.height + deltaY);
// 여백을 px로 변환
const marginRightPx = margins.right * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
// 캔버스 경계 체크
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
const maxWidth = canvasWidthPx - marginRightPx - component.x;
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
const boundedWidth = Math.min(newWidth, maxWidth);
const boundedHeight = Math.min(newHeight, maxHeight);
// Grid Snap 적용
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight),
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// 가이드라인 초기화
clearAlignmentGuides();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
isDragging,
isResizing,
dragStart.x,
dragStart.y,
resizeStart.x,
resizeStart.y,
resizeStart.width,
resizeStart.height,
component.id,
component.x,
component.y,
component.width,
component.height,
component.groupId,
isGrouped,
components,
updateComponent,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
]);
// 표시할 값 결정
const getDisplayValue = (): string => {
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
if (component.queryId && component.fieldName) {
const queryResult = getQueryResult(component.queryId);
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
if (queryResult && queryResult.rows.length > 0) {
const firstRow = queryResult.rows[0];
const value = firstRow[component.fieldName];
// 값이 있으면 문자열로 변환하여 반환
if (value !== null && value !== undefined) {
return String(value);
}
}
// 실행 결과가 없거나 값이 없으면 필드명 표시
return `{${component.fieldName}}`;
}
// 기본값이 있으면 기본값 표시
if (component.defaultValue) {
return component.defaultValue;
}
// 둘 다 없으면 타입에 따라 기본 텍스트
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
};
// 컴포넌트 타입별 렌더링
const renderContent = () => {
const displayValue = getDisplayValue();
const hasBinding = component.queryId && component.fieldName;
switch (component.type) {
case "text":
return (
<div className="h-full w-full">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span> </span>
{hasBinding && <span className="text-blue-600"> </span>}
</div>
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
className="w-full"
>
{displayValue}
</div>
</div>
);
case "label":
return (
<div className="h-full w-full">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span></span>
{hasBinding && <span className="text-blue-600"> </span>}
</div>
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
</div>
);
case "table":
// 테이블은 쿼리 결과의 모든 행과 필드를 표시
if (component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
width: undefined,
align: "left" as const,
}));
return (
<div className="h-full w-full overflow-auto">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span></span>
<span className="text-blue-600"> ({queryResult.rows.length})</span>
</div>
<table
className="w-full border-collapse text-xs"
style={{
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
}
// 기본 테이블 (데이터 없을 때)
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
</div>
);
case "image":
return (
<div className="h-full w-full overflow-hidden">
<div className="mb-1 text-xs text-gray-500"></div>
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="이미지"
style={{
width: "100%",
height: "calc(100% - 20px)",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div className="flex h-[calc(100%-20px)] w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
)}
</div>
);
case "divider":
const lineWidth = component.lineWidth || 1;
const lineColor = component.lineColor || "#000000";
return (
<div className="flex h-full w-full items-center justify-center">
<div
style={{
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`,
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`,
backgroundColor: lineColor,
...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${lineColor} 0px,
${lineColor} 10px,
transparent 10px,
transparent 20px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${lineColor} 0px,
${lineColor} 3px,
transparent 3px,
transparent 10px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "double" && {
boxShadow:
component.orientation === "horizontal"
? `0 ${lineWidth * 2}px 0 0 ${lineColor}`
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
}),
}}
/>
</div>
);
case "signature":
const sigLabelPos = component.labelPosition || "left";
const sigShowLabel = component.showLabel !== false;
const sigLabelText = component.labelText || "서명:";
const sigShowUnderline = component.showUnderline !== false;
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div
className={`flex h-[calc(100%-20px)] gap-2 ${
sigLabelPos === "top"
? "flex-col"
: sigLabelPos === "bottom"
? "flex-col-reverse"
: sigLabelPos === "right"
? "flex-row-reverse"
: "flex-row"
}`}
>
{sigShowLabel && (
<div
className="flex items-center justify-center text-xs font-medium"
style={{
width: sigLabelPos === "left" || sigLabelPos === "right" ? "auto" : "100%",
minWidth: sigLabelPos === "left" || sigLabelPos === "right" ? "40px" : "auto",
}}
>
{sigLabelText}
</div>
)}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
}}
>
</div>
)}
{sigShowUnderline && (
<div
className="absolute right-0 bottom-0 left-0"
style={{
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
</div>
);
case "stamp":
const stampShowLabel = component.showLabel !== false;
const stampLabelText = component.labelText || "(인)";
const stampPersonName = component.personName || "";
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] gap-2">
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
borderRadius: "50%",
}}
>
</div>
)}
{stampShowLabel && (
<div
className="absolute inset-0 flex items-center justify-center text-xs font-medium"
style={{
pointerEvents: "none",
}}
>
{stampLabelText}
</div>
)}
</div>
</div>
</div>
);
default:
return <div> </div>;
}
};
return (
<div
ref={componentRef}
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
isSelected
? isLocked
? "ring-2 ring-red-500"
: "ring-2 ring-blue-500"
: isMultiSelected
? isLocked
? "ring-2 ring-red-300"
: "ring-2 ring-blue-300"
: ""
}`}
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
zIndex: component.zIndex,
backgroundColor: component.backgroundColor,
border: component.borderWidth
? `${component.borderWidth}px solid ${component.borderColor}`
: "1px solid #e5e7eb",
}}
onMouseDown={handleMouseDown}
>
{renderContent()}
{/* 잠금 표시 */}
{isLocked && (
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
)}
{/* 그룹화 표시 */}
{isGrouped && !isLocked && (
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
)}
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
{isSelected && !isLocked && (
<div
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
style={{ transform: "translate(50%, 50%)" }}
onMouseDown={handleResizeStart}
/>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
interface ComponentItem {
type: string;
label: string;
icon: React.ReactNode;
}
const COMPONENTS: ComponentItem[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
const [{ isDragging }, drag] = useDrag(() => ({
type: "component",
item: { componentType: type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm transition-all hover:border-blue-500 hover:bg-blue-50 ${
isDragging ? "opacity-50" : ""
}`}
>
{icon}
<span>{label}</span>
</div>
);
}
export function ComponentPalette() {
return (
<div className="space-y-2">
{COMPONENTS.map((component) => (
<DraggableComponentItem key={component.type} {...component} />
))}
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { GridConfig } from "@/types/report";
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({ gridConfig, pageWidth, pageHeight }: GridLayerProps) {
if (!gridConfig.visible) return null;
const { cellWidth, cellHeight, columns, rows, gridColor, gridOpacity } = gridConfig;
// SVG로 그리드 선 렌더링
return (
<svg className="pointer-events-none absolute inset-0" width={pageWidth} height={pageHeight} style={{ zIndex: 0 }}>
{/* 세로 선 */}
{Array.from({ length: columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * cellWidth}
y1={0}
x2={i * cellWidth}
y2={pageHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
{/* 가로 선 */}
{Array.from({ length: rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * cellHeight}
x2={pageWidth}
y2={i * cellHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
</svg>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.visible} onCheckedChange={(visible) => updateGridConfig({ visible })} />
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.snapToGrid} onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })} />
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
onValueChange={(value) => {
const presets: Record<string, { cellWidth: number; cellHeight: number }> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine"> (10x10)</SelectItem>
<SelectItem value="medium"> (20x20)</SelectItem>
<SelectItem value="coarse"> (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 셀 너비 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
</div>
<Slider
value={[gridConfig.cellWidth]}
onValueChange={([value]) => updateGridConfig({ cellWidth: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 셀 높이 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
</div>
<Slider
value={[gridConfig.cellHeight]}
onValueChange={([value]) => updateGridConfig({ cellHeight: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 그리드 투명도 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
</div>
<Slider
value={[gridConfig.gridOpacity * 100]}
onValueChange={([value]) => updateGridConfig({ gridOpacity: value / 100 })}
min={10}
max={100}
step={10}
className="w-full"
/>
</div>
{/* 그리드 색상 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 w-16 cursor-pointer"
/>
<Input
type="text"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 flex-1 font-mono text-xs"
placeholder="#e5e7eb"
/>
</div>
</div>
{/* 그리드 정보 */}
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.rows}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.columns}</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { Plus, Copy, Trash2, GripVertical, Edit2, Check, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function PageListPanel() {
const {
layoutConfig,
currentPageId,
addPage,
deletePage,
duplicatePage,
reorderPages,
selectPage,
updatePageSettings,
} = useReportDesigner();
const [editingPageId, setEditingPageId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const handleStartEdit = (pageId: string, currentName: string) => {
setEditingPageId(pageId);
setEditingName(currentName);
};
const handleSaveEdit = () => {
if (editingPageId && editingName.trim()) {
updatePageSettings(editingPageId, { page_name: editingName.trim() });
}
setEditingPageId(null);
setEditingName("");
};
const handleCancelEdit = () => {
setEditingPageId(null);
setEditingName("");
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
// 실시간으로 순서 변경하지 않고, drop 시에만 변경
};
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedIndex === null) return;
const sourceIndex = draggedIndex;
if (sourceIndex !== targetIndex) {
reorderPages(sourceIndex, targetIndex);
}
setDraggedIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
return (
<div className="bg-background flex h-full w-64 flex-col border-r">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" variant="ghost" onClick={() => addPage()}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 페이지 목록 */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-2">
<div className="space-y-2">
{layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page, index) => (
<div
key={page.page_id}
className={`group relative cursor-pointer rounded-md border p-2 transition-all ${
page.page_id === currentPageId
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-accent/50"
} ${draggedIndex === index ? "opacity-50" : ""}`}
onClick={() => selectPage(page.page_id)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
>
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<div
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(index);
}}
onDragEnd={handleDragEnd}
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3 w-3" />
</div>
{/* 페이지 정보 */}
<div className="min-w-0 flex-1">
{editingPageId === page.page_id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEdit();
if (e.key === "Escape") handleCancelEdit();
}}
className="h-6 text-xs"
autoFocus
/>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}>
<Check className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<div className="truncate text-xs font-medium">{page.page_name}</div>
)}
<div className="text-muted-foreground text-[10px]">
{page.width}x{page.height}mm {page.components.length}
</div>
</div>
{/* 액션 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<span className="sr-only"></span>
<span className="text-sm leading-none"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleStartEdit(page.page_id, page.page_name);
}}
>
<Edit2 className="mr-2 h-3 w-3" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
duplicatePage(page.page_id);
}}
>
<Copy className="mr-2 h-3 w-3" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
deletePage(page.page_id);
}}
disabled={layoutConfig.pages.length <= 1}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3 w-3" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
{/* 푸터 */}
<div className="border-t p-2">
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,477 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Plus, Trash2, Play, AlertCircle, Database, Link2 } from "lucide-react";
import { useReportDesigner, ReportQuery } from "@/contexts/ReportDesignerContext";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import type { ExternalConnection } from "@/types/report";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
// SQL 쿼리 안전성 검증 함수 (컴포넌트 외부에 선언)
const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: string | null } => {
if (!sql || sql.trim() === "") {
return { isValid: false, errorMessage: "쿼리를 입력해주세요." };
}
// 위험한 SQL 명령어 목록
const dangerousKeywords = [
"DELETE",
"DROP",
"TRUNCATE",
"INSERT",
"UPDATE",
"ALTER",
"CREATE",
"REPLACE",
"MERGE",
"GRANT",
"REVOKE",
"EXECUTE",
"EXEC",
"CALL",
];
// SQL을 대문자로 변환하여 검사
const upperSql = sql.toUpperCase().trim();
// 위험한 키워드 검사
for (const keyword of dangerousKeywords) {
// 단어 경계를 고려하여 검사
const regex = new RegExp(`\\b${keyword}\\b`, "i");
if (regex.test(upperSql)) {
return {
isValid: false,
errorMessage: `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`,
};
}
}
// SELECT 쿼리인지 확인
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
return {
isValid: false,
errorMessage: "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다.",
};
}
// 세미콜론으로 구분된 여러 쿼리 방지
const semicolonCount = (sql.match(/;/g) || []).length;
if (semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";"))) {
return {
isValid: false,
errorMessage: "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다.",
};
}
return { isValid: true, errorMessage: null };
};
export function QueryManager() {
const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner();
const [isTestRunning, setIsTestRunning] = useState<Record<string, boolean>>({});
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({});
const [parameterTypes, setParameterTypes] = useState<Record<string, Record<string, string>>>({});
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const { toast } = useToast();
// 각 쿼리의 안전성 검증 결과
const getQueryValidation = (query: ReportQuery) => validateQuerySafety(query.sqlQuery);
// 외부 DB 연결 목록 조회
useEffect(() => {
const fetchConnections = async () => {
setIsLoadingConnections(true);
try {
const response = await reportApi.getExternalConnections();
if (response.success && response.data) {
setExternalConnections(response.data);
}
} catch (error) {
console.error("외부 DB 연결 목록 조회 실패:", error);
} finally {
setIsLoadingConnections(false);
}
};
fetchConnections();
}, []);
// 파라미터 감지 ($1, $2 등, 단 작은따옴표 안은 제외)
const detectParameters = (sql: string): string[] => {
// 작은따옴표 안의 내용을 제거
const withoutStrings = sql.replace(/'[^']*'/g, "");
// $숫자 패턴 찾기
const matches = withoutStrings.match(/\$\d+/g);
if (!matches) return [];
// 중복 제거하되 등장 순서 유지
const seen = new Set<string>();
const result: string[] = [];
for (const match of matches) {
if (!seen.has(match)) {
seen.add(match);
result.push(match);
}
}
return result;
};
// 새 쿼리 추가
const handleAddQuery = () => {
const newQuery: ReportQuery = {
id: `query_${Date.now()}`,
name: `쿼리 ${queries.length + 1}`,
type: "MASTER",
sqlQuery: "",
parameters: [],
externalConnectionId: null,
};
setQueries([...queries, newQuery]);
};
// 쿼리 삭제
const handleDeleteQuery = (queryId: string, e: React.MouseEvent) => {
e.stopPropagation();
setQueries(queries.filter((q) => q.id !== queryId));
// 해당 쿼리의 상태 정리
const newParameterValues = { ...parameterValues };
const newParameterTypes = { ...parameterTypes };
const newIsTestRunning = { ...isTestRunning };
delete newParameterValues[queryId];
delete newParameterTypes[queryId];
delete newIsTestRunning[queryId];
setParameterValues(newParameterValues);
setParameterTypes(newParameterTypes);
setIsTestRunning(newIsTestRunning);
};
// 파라미터 값이 모두 입력되었는지 확인
const isAllParametersFilled = (query: ReportQuery): boolean => {
if (!query || query.parameters.length === 0) {
return true;
}
const queryParams = parameterValues[query.id] || {};
return query.parameters.every((param) => {
const value = queryParams[param];
return value !== undefined && value.trim() !== "";
});
};
// 쿼리 업데이트
const handleUpdateQuery = (queryId: string, updates: Partial<ReportQuery>) => {
setQueries(
queries.map((q) => {
if (q.id === queryId) {
const updated = { ...q, ...updates };
// SQL이 변경되면 파라미터 재감지
if (updates.sqlQuery !== undefined) {
updated.parameters = detectParameters(updated.sqlQuery);
}
return updated;
}
return q;
}),
);
};
// 쿼리 테스트 실행
const handleTestQuery = async (query: ReportQuery) => {
// SQL 쿼리 안전성 검증
const validation = validateQuerySafety(query.sqlQuery);
if (!validation.isValid) {
toast({
title: "쿼리 검증 실패",
description: validation.errorMessage || "잘못된 쿼리입니다.",
variant: "destructive",
});
return;
}
setIsTestRunning({ ...isTestRunning, [query.id]: true });
try {
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
const externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {};
const response = await reportApi.executeQuery(
testReportId,
query.id,
queryParams,
sqlQuery,
externalConnectionId,
);
if (response.success && response.data) {
setQueryResult(query.id, response.data.fields, response.data.rows);
toast({
title: "성공",
description: `${response.data.rows.length}건의 데이터가 조회되었습니다.`,
});
}
} catch (error: any) {
toast({
title: "오류",
description: error.response?.data?.message || "쿼리 실행에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsTestRunning({ ...isTestRunning, [query.id]: false });
}
};
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" onClick={handleAddQuery}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 안내 메시지 */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
<strong> </strong> 1 ,
<strong> </strong> .
</AlertDescription>
</Alert>
{/* 아코디언 목록 */}
{queries.length > 0 ? (
<Accordion type="single" collapsible>
{queries.map((query) => {
const testResult = getQueryResult(query.id);
const queryValidation = getQueryValidation(query);
const queryParams = parameterValues[query.id] || {};
const queryParamTypes = parameterTypes[query.id] || {};
return (
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{query.name}</span>
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
{query.type}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
{/* 쿼리 이름 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={query.name}
onChange={(e) => handleUpdateQuery(query.id, { name: e.target.value })}
placeholder="쿼리 이름"
className="h-8"
/>
</div>
{/* 쿼리 타입 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={query.type}
onValueChange={(value: "MASTER" | "DETAIL") => handleUpdateQuery(query.id, { type: value })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MASTER"> (1)</SelectItem>
<SelectItem value="DETAIL"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 연결 선택 */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs">
<Link2 className="h-3 w-3" />
DB
</Label>
<Select
value={(query as any).externalConnectionId?.toString() || "internal"}
onValueChange={(value) =>
handleUpdateQuery(query.id, {
externalConnectionId: value === "internal" ? null : parseInt(value),
} as any)
}
disabled={isLoadingConnections}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
DB (PostgreSQL)
</div>
</SelectItem>
{externalConnections.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500"> DB</div>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{conn.connection_name}
<Badge variant="outline" className="text-xs">
{conn.db_type.toUpperCase()}
</Badge>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{/* SQL 쿼리 */}
<div className="space-y-2">
<Textarea
value={query.sqlQuery}
onChange={(e) => handleUpdateQuery(query.id, { sqlQuery: e.target.value })}
placeholder="SELECT * FROM orders WHERE order_id = $1"
className="min-h-[150px] font-mono text-xs"
/>
</div>
{/* 파라미터 입력 */}
{query.parameters.length > 0 && (
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<Label className="text-xs font-semibold text-yellow-800"></Label>
</div>
<div className="space-y-2">
{query.parameters.map((param) => {
const paramType = queryParamTypes[param] || "text";
return (
<div key={param} className="flex items-center gap-2">
<Label className="w-12 text-xs font-semibold">{param}</Label>
<Select
value={paramType}
onValueChange={(value) =>
setParameterTypes({
...parameterTypes,
[query.id]: {
...queryParamTypes,
[param]: value,
},
})
}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
</SelectContent>
</Select>
<Input
type={paramType === "number" ? "number" : paramType === "date" ? "date" : "text"}
placeholder="값"
className="h-8 flex-1"
value={queryParams[param] || ""}
onChange={(e) =>
setParameterValues({
...parameterValues,
[query.id]: {
...queryParams,
[param]: e.target.value,
},
})
}
/>
</div>
);
})}
</div>
</div>
)}
{/* SQL 검증 경고 메시지 */}
{!queryValidation.isValid && queryValidation.errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{queryValidation.errorMessage}</AlertDescription>
</Alert>
)}
{/* 테스트 실행 */}
<Button
size="sm"
variant="default"
className="w-full bg-red-500 hover:bg-red-600"
onClick={() => handleTestQuery(query)}
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
>
<Play className="mr-2 h-4 w-4" />
{isTestRunning[query.id] ? "실행 중..." : "실행"}
</Button>
{/* 결과 필드 */}
{testResult && (
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
<Label className="text-xs font-semibold text-green-800"> </Label>
<div className="flex flex-wrap gap-2">
{testResult.fields.map((field) => (
<Badge key={field} variant="default" className="bg-teal-500">
{field}
</Badge>
))}
</div>
<p className="text-xs text-green-700">{testResult.rows.length} .</p>
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Database className="mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">
<br />
</p>
</div>
)}
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,402 @@
"use client";
import { useRef, useEffect } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { GridLayer } from "./GridLayer";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
currentPageId,
currentPage,
components,
addComponent,
updateComponent,
canvasWidth,
canvasHeight,
margins,
selectComponent,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
alignmentGuides,
copyComponents,
pasteComponents,
undo,
redo,
showRuler,
gridConfig,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const canvasRect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
// 컴포넌트 타입별 기본 설정
let width = 200;
let height = 100;
if (item.componentType === "table") {
height = 200;
} else if (item.componentType === "image") {
width = 150;
height = 150;
} else if (item.componentType === "divider") {
width = 300;
height = 2;
} else if (item.componentType === "signature") {
width = 120;
height = 70;
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
}
// 여백을 px로 변환 (1mm ≈ 3.7795px)
const marginTopPx = margins.top * 3.7795;
const marginLeftPx = margins.left * 3.7795;
const marginRightPx = margins.right * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
// 캔버스 경계 (px)
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 드롭 위치 계산 (여백 내부로 제한)
const rawX = x - 100;
const rawY = y - 25;
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - width;
const maxY = canvasHeightPx - marginBottomPx - height;
const boundedX = Math.min(Math.max(minX, rawX), maxX);
const boundedY = Math.min(Math.max(minY, rawY), maxY);
// 새 컴포넌트 생성 (Grid Snap 적용)
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
x: snapValueToGrid(boundedX),
y: snapValueToGrid(boundedY),
width: snapValueToGrid(width),
height: snapValueToGrid(height),
zIndex: components.length,
fontSize: 13,
fontFamily: "Malgun Gothic",
fontWeight: "normal",
fontColor: "#000000",
backgroundColor: "transparent",
borderWidth: 0,
borderColor: "#cccccc",
borderRadius: 5,
textAlign: "left",
padding: 10,
visible: true,
printable: true,
// 이미지 전용
...(item.componentType === "image" && {
imageUrl: "",
objectFit: "contain" as const,
}),
// 구분선 전용
...(item.componentType === "divider" && {
orientation: "horizontal" as const,
lineStyle: "solid" as const,
lineWidth: 1,
lineColor: "#000000",
}),
// 서명란 전용
...(item.componentType === "signature" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
showUnderline: true,
borderWidth: 0,
borderColor: "#cccccc",
}),
// 도장란 전용
...(item.componentType === "stamp" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "(인)",
labelPosition: "top" as const,
personName: "",
borderWidth: 0,
borderColor: "#cccccc",
}),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
tableColumns: [],
headerBackgroundColor: "#f3f4f6",
headerTextColor: "#111827",
showBorder: true,
rowHeight: 32,
}),
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
selectComponent(null);
}
};
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 단축키 무시
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return;
}
// 화살표 키: 선택된 컴포넌트 이동
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault();
// 선택된 컴포넌트가 없으면 무시
if (!selectedComponentId && selectedComponentIds.length === 0) {
return;
}
// 이동 거리 (Shift 키를 누르면 10px, 아니면 1px)
const moveDistance = e.shiftKey ? 10 : 1;
// 이동할 컴포넌트 ID 목록
const idsToMove =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
idsToMove.forEach((id) => {
const component = components.find((c) => c.id === id);
if (!component || component.locked) return;
let newX = component.x;
let newY = component.y;
switch (e.key) {
case "ArrowLeft":
newX = Math.max(0, component.x - moveDistance);
break;
case "ArrowRight":
newX = component.x + moveDistance;
break;
case "ArrowUp":
newY = Math.max(0, component.y - moveDistance);
break;
case "ArrowDown":
newY = component.y + moveDistance;
break;
}
updateComponent(id, { x: newX, y: newY });
});
return;
}
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
if (e.key === "Delete") {
if (selectedComponentIds.length > 0) {
selectedComponentIds.forEach((id) => {
const component = components.find((c) => c.id === id);
if (component && !component.locked) {
removeComponent(id);
}
});
} else if (selectedComponentId) {
const component = components.find((c) => c.id === selectedComponentId);
if (component && !component.locked) {
removeComponent(selectedComponentId);
}
}
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
e.preventDefault();
copyComponents();
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
pasteComponents();
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
return;
}
// Ctrl+Z (또는 Cmd+Z): Undo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedComponentId,
selectedComponentIds,
components,
removeComponent,
copyComponents,
pasteComponents,
undo,
redo,
]);
// 페이지가 없는 경우
if (!currentPageId || !currentPage) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-700"> </h3>
<p className="mt-2 text-sm text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
</div>
{/* 캔버스 스크롤 영역 */}
<div className="flex flex-1 items-center justify-center overflow-auto p-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */}
{showRuler && (
<div className="flex">
{/* 좌상단 코너 (20x20) */}
<div className="h-5 w-5 bg-gray-200" />
{/* 가로 눈금자 */}
<Ruler orientation="horizontal" length={canvasWidth} />
</div>
)}
{/* 세로 눈금자 + 캔버스 */}
<div className="flex">
{/* 세로 눈금자 */}
{showRuler && <Ruler orientation="vertical" length={canvasHeight} />}
{/* 캔버스 */}
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
}}
onClick={handleCanvasClick}
>
{/* 그리드 레이어 */}
<GridLayer
gridConfig={gridConfig}
pageWidth={canvasWidth * 3.7795} // mm to px
pageHeight={canvasHeight * 3.7795}
/>
{/* 페이지 여백 가이드 */}
{currentPage && (
<div
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
style={{
top: `${currentPage.margins.top}mm`,
left: `${currentPage.margins.left}mm`,
right: `${currentPage.margins.right}mm`,
bottom: `${currentPage.margins.bottom}mm`,
}}
/>
)}
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ComponentPalette } from "./ComponentPalette";
import { TemplatePalette } from "./TemplatePalette";
export function ReportDesignerLeftPanel() {
return (
<div className="w-80 border-r bg-white">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 템플릿 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"> 릿</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<TemplatePalette />
</CardContent>
</Card>
{/* 컴포넌트 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ComponentPalette />
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,496 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Save,
Eye,
RotateCcw,
ArrowLeft,
Loader2,
BookTemplate,
Grid3x3,
Undo2,
Redo2,
AlignLeft,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyEnd,
AlignCenterHorizontal,
AlignCenterVertical,
AlignHorizontalDistributeCenter,
AlignVerticalDistributeCenter,
RectangleHorizontal,
RectangleVertical,
Square,
ChevronDown,
ChevronsDown,
ChevronsUp,
ChevronUp,
Lock,
Unlock,
Ruler as RulerIcon,
Group,
Ungroup,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { ReportPreviewModal } from "./ReportPreviewModal";
export function ReportDesignerToolbar() {
const router = useRouter();
const {
reportDetail,
saveLayout,
isSaving,
loadLayout,
components,
canvasWidth,
canvasHeight,
queries,
snapToGrid,
setSnapToGrid,
showGrid,
setShowGrid,
undo,
redo,
canUndo,
canRedo,
selectedComponentIds,
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
bringToFront,
sendToBack,
bringForward,
sendBackward,
toggleLock,
lockComponents,
unlockComponents,
showRuler,
setShowRuler,
groupComponents,
ungroupComponents,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { toast } = useToast();
// 버튼 활성화 조건
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
const hasSelection = selectedComponentIds && selectedComponentIds.length >= 1;
const canGroup = selectedComponentIds && selectedComponentIds.length >= 2;
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
const canSaveAsTemplate = components.length > 0;
// Grid 토글 (Snap과 Grid 표시 함께 제어)
const handleToggleGrid = () => {
const newValue = !snapToGrid;
setSnapToGrid(newValue);
setShowGrid(newValue);
};
const handleSave = async () => {
await saveLayout();
};
const handleSaveAndClose = async () => {
await saveLayout();
router.push("/admin/report");
};
const handleReset = async () => {
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
await loadLayout();
}
};
const handleBack = () => {
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
router.push("/admin/report");
}
};
const handleSaveAsTemplate = async (data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
}) => {
try {
// 현재 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
const response = await reportApi.createTemplateFromLayout({
templateNameKor: data.templateNameKor,
templateNameEng: data.templateNameEng,
templateType: reportDetail?.report?.report_type || "GENERAL",
description: data.description,
layoutConfig: {
width: canvasWidth,
height: canvasHeight,
orientation: "portrait",
margins: {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
components: components,
},
defaultQueries: queries.map((q, index) => ({
name: q.name,
type: q.type,
sqlQuery: q.sqlQuery,
parameters: q.parameters,
externalConnectionId: q.externalConnectionId || null,
displayOrder: index,
})),
});
if (response.success) {
toast({
title: "성공",
description: "템플릿이 생성되었습니다.",
});
setShowSaveAsTemplate(false);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response?.data?.message ||
"템플릿 생성에 실패했습니다."
: "템플릿 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
throw error;
}
};
return (
<>
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-gray-300" />
<div>
<h2 className="text-lg font-semibold text-gray-900">
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
</h2>
{reportDetail?.report.report_name_eng && (
<p className="text-sm text-gray-500">{reportDetail.report.report_name_eng}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={snapToGrid && showGrid ? "default" : "outline"}
size="sm"
onClick={handleToggleGrid}
className="gap-2"
title="Grid Snap 및 표시 켜기/끄기"
>
<Grid3x3 className="h-4 w-4" />
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
</Button>
<Button
variant={showRuler ? "default" : "outline"}
size="sm"
onClick={() => setShowRuler(!showRuler)}
className="gap-2"
title="눈금자 표시 켜기/끄기"
>
<RulerIcon className="h-4 w-4" />
{showRuler ? "눈금자 ON" : "눈금자 OFF"}
</Button>
<Button
variant="outline"
size="sm"
onClick={undo}
disabled={!canUndo}
className="gap-2"
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={redo}
disabled={!canRedo}
className="gap-2"
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
{/* 정렬 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="정렬 (2개 이상 선택 필요)"
>
<AlignLeft className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={alignLeft}>
<AlignLeft className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignRight}>
<AlignRight className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignTop}>
<AlignVerticalJustifyStart className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignBottom}>
<AlignVerticalJustifyEnd className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={alignCenterHorizontal}>
<AlignCenterHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignCenterVertical}>
<AlignCenterVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 배치 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canDistribute}
className="gap-2"
title="균등 배치 (3개 이상 선택 필요)"
>
<AlignHorizontalDistributeCenter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={distributeHorizontal}>
<AlignHorizontalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={distributeVertical}>
<AlignVerticalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 크기 조정 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="크기 조정 (2개 이상 선택 필요)"
>
<Square className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={makeSameWidth}>
<RectangleHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameHeight}>
<RectangleVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameSize}>
<Square className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 레이어 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="레이어 순서 (1개 이상 선택 필요)"
>
<ChevronsUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={bringToFront}>
<ChevronsUp className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={bringForward}>
<ChevronUp className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={sendBackward}>
<ChevronDown className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={sendToBack}>
<ChevronsDown className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 잠금 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="컴포넌트 잠금/해제 (1개 이상 선택 필요)"
>
<Lock className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={toggleLock}>
<Lock className="mr-2 h-4 w-4" />
(/)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={lockComponents}>
<Lock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={unlockComponents}>
<Unlock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 그룹화 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="컴포넌트 그룹화/해제"
>
<Group className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
<Group className="mr-2 h-4 w-4" />
(2 )
</DropdownMenuItem>
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
<Ungroup className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={!canSaveAsTemplate}
className="gap-2"
title={!canSaveAsTemplate ? "컴포넌트를 추가한 후 템플릿으로 저장할 수 있습니다" : ""}
>
<BookTemplate className="h-4 w-4" />
릿
</Button>
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
<Button size="sm" onClick={handleSaveAndClose} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
</div>
</div>
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
<SaveAsTemplateModal
isOpen={showSaveAsTemplate}
onClose={() => setShowSaveAsTemplate(false)}
onSave={handleSaveAsTemplate}
/>
</>
);
}

View File

@ -0,0 +1,918 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
// @ts-ignore - docx 라이브러리 타입 이슈
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableCell,
TableRow,
WidthType,
ImageRun,
AlignmentType,
VerticalAlign,
convertInchesToTwip,
} from "docx";
import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
const [isExporting, setIsExporting] = useState(false);
const { toast } = useToast();
// 컴포넌트의 실제 표시 값 가져오기
const getComponentValue = (component: any): string => {
if (component.queryId && component.fieldName) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const value = queryResult.rows[0][component.fieldName];
if (value !== null && value !== undefined) {
return String(value);
}
}
return `{${component.fieldName}}`;
}
return component.defaultValue || "텍스트";
};
const handlePrint = () => {
// HTML 생성하여 인쇄
const printHtml = generatePrintHTML();
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(printHtml);
printWindow.document.close();
printWindow.print();
};
// 페이지별 컴포넌트 HTML 생성
const generatePageHTML = (
pageComponents: any[],
pageWidth: number,
pageHeight: number,
backgroundColor: string,
): string => {
const componentsHTML = pageComponents
.map((component) => {
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
let content = "";
// Text/Label 컴포넌트
if (component.type === "text" || component.type === "label") {
const displayValue = getComponentValue(component);
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`;
}
// Image 컴포넌트
else if (component.type === "image" && component.imageUrl) {
const imageUrl = component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl);
content = `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />`;
}
// Divider 컴포넌트
else if (component.type === "divider") {
const width = component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`;
const height = component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`;
content = `<div style="width: ${width}; height: ${height}; background-color: ${component.lineColor || "#000000"};"></div>`;
}
// Signature 컴포넌트
else if (component.type === "signature") {
const labelPosition = component.labelPosition || "left";
const showLabel = component.showLabel !== false;
const labelText = component.labelText || "서명:";
const imageUrl = component.imageUrl
? component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl)
: "";
if (labelPosition === "left" || labelPosition === "right") {
content = `
<div style="display: flex; align-items: center; flex-direction: ${labelPosition === "right" ? "row-reverse" : "row"}; gap: 8px; height: 100%;">
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
<div style="flex: 1; position: relative;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
</div>
</div>`;
} else {
content = `
<div style="display: flex; flex-direction: column; align-items: center; height: 100%;">
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
<div style="flex: 1; width: 100%; position: relative;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
</div>
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
</div>`;
}
}
// Stamp 컴포넌트
else if (component.type === "stamp") {
const showLabel = component.showLabel !== false;
const labelText = component.labelText || "(인)";
const personName = component.personName || "";
const imageUrl = component.imageUrl
? component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl)
: "";
content = `
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""}
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"}; border-radius: 50%;" />` : ""}
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
</div>
</div>`;
}
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
align: "left" as const,
width: undefined,
}));
const tableRows = queryResult.rows
.map(
(row) => `
<tr>
${columns.map((col: { field: string; align?: string }) => `<td style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; height: ${component.rowHeight || "auto"}px;">${String(row[col.field] ?? "")}</td>`).join("")}
</tr>
`,
)
.join("");
content = `
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
<thead style="display: table-header-group; break-inside: avoid; break-after: avoid;">
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
${columns.map((col: { header: string; align?: string; width?: number }) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>`;
}
return `
<div style="position: absolute; left: ${component.x}px; top: ${component.y}px; width: ${component.width}px; height: ${component.height}px; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; padding: 8px; box-sizing: border-box;">
${content}
</div>`;
})
.join("");
return `
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
${componentsHTML}
</div>`;
};
// 모든 페이지 HTML 생성 (인쇄/PDF용)
const generatePrintHTML = (): string => {
const pagesHTML = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
.join('<div style="page-break-after: always;"></div>');
return `
<html>
<head>
<meta charset="UTF-8">
<title> </title>
<style>
* { box-sizing: border-box; }
@page {
size: A4;
margin: 10mm;
}
@media print {
body { margin: 0; padding: 0; }
.print-page { page-break-after: always; page-break-inside: avoid; }
.print-page:last-child { page-break-after: auto; }
}
body {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
margin: 0;
padding: 20px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
</style>
</head>
<body>
${pagesHTML}
<script>
window.onload = function() {
// 이미지 로드 대기 후 인쇄
const images = document.getElementsByTagName('img');
if (images.length === 0) {
setTimeout(() => window.print(), 100);
} else {
let loadedCount = 0;
Array.from(images).forEach(img => {
if (img.complete) {
loadedCount++;
} else {
img.onload = () => {
loadedCount++;
if (loadedCount === images.length) {
setTimeout(() => window.print(), 100);
}
};
}
});
if (loadedCount === images.length) {
setTimeout(() => window.print(), 100);
}
}
}
</script>
</body>
</html>`;
};
// PDF 다운로드 (브라우저 인쇄 기능 이용)
const handleDownloadPDF = () => {
const printHtml = generatePrintHTML();
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(printHtml);
printWindow.document.close();
toast({
title: "안내",
description: "인쇄 대화상자에서 'PDF로 저장'을 선택하세요.",
});
};
// Base64를 Uint8Array로 변환
const base64ToUint8Array = (base64: string): Uint8Array => {
const base64Data = base64.split(",")[1] || base64;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// 컴포넌트를 TableCell로 변환
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
const cellWidth = widthPercent || 100;
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2,
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
alignment:
component.textAlign === "center"
? AlignmentType.CENTER
: component.textAlign === "right"
? AlignmentType.RIGHT
: AlignmentType.LEFT,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} else if (component.type === "signature" || component.type === "stamp") {
if (component.imageUrl) {
try {
const imageData = base64ToUint8Array(component.imageUrl);
return new TableCell({
children: [
new Paragraph({
children: [
new ImageRun({
data: imageData,
transformation: {
width: component.width || 150,
height: component.height || 50,
},
}),
],
alignment: AlignmentType.CENTER,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} catch {
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
size: 24,
}),
],
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
return new TableCell({
children: [table],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
return null;
};
// WORD 다운로드
const handleDownloadWord = async () => {
setIsExporting(true);
try {
// 페이지별로 섹션 생성
const sections = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => {
// 페이지 크기 설정 (A4 기준)
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
const marginLeft = convertInchesToTwip(page.margins.left / 96);
const marginRight = convertInchesToTwip(page.margins.right / 96);
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
const sortedComponents = [...page.components].sort((a, b) => {
// Y좌표 우선, 같으면 X좌표
if (Math.abs(a.y - b.y) < 5) {
return a.x - b.x;
}
return a.y - b.y;
});
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
const rowTolerance = 20; // Y 좌표 허용 오차
for (const component of sortedComponents) {
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
if (existingRow) {
existingRow.push(component);
} else {
rows.push([component]);
}
}
// 각 행 내에서 X좌표로 정렬
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
const tableRows: TableRow[] = [];
for (const row of rows) {
if (row.length === 1) {
// 단일 컴포넌트 - 전체 너비 사용
const component = row[0];
const cell = createTableCell(component, pageWidth);
if (cell) {
tableRows.push(
new TableRow({
children: [cell],
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
}),
);
}
} else {
// 여러 컴포넌트 - 가로 배치
const cells: TableCell[] = [];
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
for (const component of row) {
const widthPercent = (component.width / totalWidth) * 100;
const cell = createTableCell(component, pageWidth, widthPercent);
if (cell) {
cells.push(cell);
}
}
if (cells.length > 0) {
const maxHeight = Math.max(...row.map((c) => c.height));
tableRows.push(
new TableRow({
children: cells,
height: { value: maxHeight * 15, rule: 1 },
}),
);
}
}
}
return {
properties: {
page: {
width: pageWidth,
height: pageHeight,
margin: {
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
},
},
},
children:
tableRows.length > 0
? [
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
},
}),
]
: [new Paragraph({ text: "" })],
};
});
// 문서 생성
const doc = new Document({
sections,
});
// Blob 생성 및 다운로드
const blob = await Packer.toBlob(doc);
const fileName = reportDetail?.report?.report_name_kor || "리포트";
const timestamp = new Date().toISOString().slice(0, 10);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}_${timestamp}.docx`;
link.click();
window.URL.revokeObjectURL(url);
toast({
title: "성공",
description: "WORD 파일이 다운로드되었습니다.",
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{/* 미리보기 영역 - 모든 페이지 표시 */}
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
<div className="space-y-4">
{layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => (
<div key={page.page_id} className="relative">
{/* 페이지 번호 라벨 */}
<div className="mb-2 text-center text-xs text-gray-500">
{page.page_order + 1} - {page.page_name}
</div>
{/* 페이지 컨텐츠 */}
<div
className="relative mx-auto shadow-lg"
style={{
width: `${page.width}mm`,
minHeight: `${page.height}mm`,
backgroundColor: page.background_color,
}}
>
{page.components.map((component) => {
const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
return (
<div
key={component.id}
className="absolute"
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
backgroundColor: component.backgroundColor,
border: component.borderWidth
? `${component.borderWidth}px solid ${component.borderColor}`
: "none",
padding: "8px",
}}
>
{component.type === "text" && (
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
)}
{component.type === "label" && (
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
)}
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
(() => {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
align: "left" as const,
width: undefined,
}));
return (
<table
style={{
width: "100%",
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
fontSize: "12px",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
);
})()
) : component.type === "table" ? (
<div className="text-xs text-gray-400"> </div>
) : null}
{component.type === "image" && component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="이미지"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.type === "divider" && (
<div
style={{
width:
component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
backgroundColor: component.lineColor || "#000000",
...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${component.lineColor || "#000000"} 0px,
${component.lineColor || "#000000"} 10px,
transparent 10px,
transparent 20px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${component.lineColor || "#000000"} 0px,
${component.lineColor || "#000000"} 3px,
transparent 3px,
transparent 10px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "double" && {
boxShadow:
component.orientation === "horizontal"
? `0 ${(component.lineWidth || 1) * 2}px 0 0 ${component.lineColor || "#000000"}`
: `${(component.lineWidth || 1) * 2}px 0 0 0 ${component.lineColor || "#000000"}`,
}),
}}
/>
)}
{component.type === "signature" && (
<div
style={{
display: "flex",
gap: "8px",
flexDirection:
component.labelPosition === "top" || component.labelPosition === "bottom"
? "column"
: "row",
...(component.labelPosition === "right" || component.labelPosition === "bottom"
? {
flexDirection:
component.labelPosition === "right" ? "row-reverse" : "column-reverse",
}
: {}),
}}
>
{component.showLabel !== false && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
minWidth:
component.labelPosition === "left" || component.labelPosition === "right"
? "40px"
: "auto",
}}
>
{component.labelText || "서명:"}
</div>
)}
<div style={{ flex: 1, position: "relative" }}>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showUnderline !== false && (
<div
style={{
position: "absolute",
bottom: "0",
left: "0",
right: "0",
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
)}
{component.type === "stamp" && (
<div
style={{
display: "flex",
gap: "8px",
width: "100%",
height: "100%",
}}
>
{component.personName && (
<div
style={{
display: "flex",
alignItems: "center",
fontSize: "12px",
fontWeight: "500",
}}
>
{component.personName}
</div>
)}
<div
style={{
position: "relative",
flex: 1,
}}
>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showLabel !== false && (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
pointerEvents: "none",
}}
>
{component.labelText || "(인)"}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isExporting}>
</Button>
<Button variant="outline" onClick={handlePrint} disabled={isExporting} className="gap-2">
<Printer className="h-4 w-4" />
</Button>
<Button onClick={handleDownloadPDF} className="gap-2">
<FileDown className="h-4 w-4" />
PDF
</Button>
<Button onClick={handleDownloadWord} disabled={isExporting} variant="secondary" className="gap-2">
<FileText className="h-4 w-4" />
{isExporting ? "생성 중..." : "WORD"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { JSX } from "react";
interface RulerProps {
orientation: "horizontal" | "vertical";
length: number; // mm 단위
offset?: number; // 스크롤 오프셋 (px)
}
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준)
const mmToPx = (mm: number) => mm * 3.7795;
const lengthPx = mmToPx(length);
const isHorizontal = orientation === "horizontal";
// 눈금 생성 (10mm 단위 큰 눈금, 5mm 단위 중간 눈금, 1mm 단위 작은 눈금)
const renderTicks = () => {
const ticks: JSX.Element[] = [];
const maxMm = length;
for (let mm = 0; mm <= maxMm; mm++) {
const px = mmToPx(mm);
// 10mm 단위 큰 눈금
if (mm % 10 === 0) {
ticks.push(
<div
key={`major-${mm}`}
className="absolute bg-gray-700"
style={
isHorizontal
? {
left: `${px}px`,
top: "0",
width: "1px",
height: "12px",
}
: {
top: `${px}px`,
left: "0",
height: "1px",
width: "12px",
}
}
/>,
);
// 숫자 표시 (10mm 단위)
if (mm > 0) {
ticks.push(
<div
key={`label-${mm}`}
className="absolute text-[9px] text-gray-600"
style={
isHorizontal
? {
left: `${px + 2}px`,
top: "0px",
}
: {
top: `${px + 2}px`,
left: "0px",
writingMode: "vertical-lr",
}
}
>
{mm}
</div>,
);
}
}
// 5mm 단위 중간 눈금
else if (mm % 5 === 0) {
ticks.push(
<div
key={`medium-${mm}`}
className="absolute bg-gray-500"
style={
isHorizontal
? {
left: `${px}px`,
top: "4px",
width: "1px",
height: "8px",
}
: {
top: `${px}px`,
left: "4px",
height: "1px",
width: "8px",
}
}
/>,
);
}
// 1mm 단위 작은 눈금
else {
ticks.push(
<div
key={`minor-${mm}`}
className="absolute bg-gray-400"
style={
isHorizontal
? {
left: `${px}px`,
top: "8px",
width: "1px",
height: "4px",
}
: {
top: `${px}px`,
left: "8px",
height: "1px",
width: "4px",
}
}
/>,
);
}
}
return ticks;
};
return (
<div
className="relative bg-gray-100 select-none"
style={
isHorizontal
? {
width: `${lengthPx}px`,
height: "20px",
}
: {
width: "20px",
height: `${lengthPx}px`,
}
}
>
{renderTicks()}
</div>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface SaveAsTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: { templateNameKor: string; templateNameEng?: string; description?: string }) => Promise<void>;
}
export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateModalProps) {
const [formData, setFormData] = useState({
templateNameKor: "",
templateNameEng: "",
description: "",
});
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
if (!formData.templateNameKor.trim()) {
alert("템플릿명을 입력해주세요.");
return;
}
setIsSaving(true);
try {
await onSave({
templateNameKor: formData.templateNameKor,
templateNameEng: formData.templateNameEng || undefined,
description: formData.description || undefined,
});
// 초기화
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
} catch (error) {
console.error("템플릿 저장 실패:", error);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>릿 </DialogTitle>
<DialogDescription>
릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="templateNameKor">
릿 () <span className="text-red-500">*</span>
</Label>
<Input
id="templateNameKor"
value={formData.templateNameKor}
onChange={(e) =>
setFormData({
...formData,
templateNameKor: e.target.value,
})
}
placeholder="예: 발주서 양식"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="templateNameEng">릿 ()</Label>
<Input
id="templateNameEng"
value={formData.templateNameEng}
onChange={(e) =>
setFormData({
...formData,
templateNameEng: e.target.value,
})
}
placeholder="예: Purchase Order Template"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="템플릿에 대한 간단한 설명을 입력하세요"
rows={3}
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"저장"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,254 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Wand2 } from "lucide-react";
interface SignatureGeneratorProps {
onSignatureSelect: (dataUrl: string) => void;
}
// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들)
const SIGNATURE_FONTS = {
korean: [
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
{ name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 },
{ name: "귀여운", style: "Gugi, cursive", weight: 400 },
{ name: "싱글데이", style: "'Single Day', cursive", weight: 400 },
{ name: "스타일리시", style: "Stylish, cursive", weight: 400 },
{ name: "해바라기", style: "Sunflower, sans-serif", weight: 700 },
{ name: "손글씨", style: "Gaegu, cursive", weight: 700 },
],
english: [
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },
{ name: "Dancing Script (춤추는)", style: "'Dancing Script', cursive", weight: 700 },
{ name: "Great Vibes (멋진)", style: "'Great Vibes', cursive", weight: 400 },
{ name: "Pacifico (파도)", style: "Pacifico, cursive", weight: 400 },
{ name: "Satisfy (만족)", style: "Satisfy, cursive", weight: 400 },
{ name: "Caveat (거친)", style: "Caveat, cursive", weight: 700 },
{ name: "Permanent Marker", style: "'Permanent Marker', cursive", weight: 400 },
{ name: "Shadows Into Light", style: "'Shadows Into Light', cursive", weight: 400 },
{ name: "Kalam (볼드)", style: "Kalam, cursive", weight: 700 },
{ name: "Patrick Hand", style: "'Patrick Hand', cursive", weight: 400 },
{ name: "Indie Flower", style: "'Indie Flower', cursive", weight: 400 },
{ name: "Amatic SC", style: "'Amatic SC', cursive", weight: 700 },
{ name: "Covered By Your Grace", style: "'Covered By Your Grace', cursive", weight: 400 },
],
};
export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProps) {
const [language, setLanguage] = useState<"korean" | "english">("korean");
const [name, setName] = useState("");
const [generatedSignatures, setGeneratedSignatures] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [fontsLoaded, setFontsLoaded] = useState(false);
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const fonts = SIGNATURE_FONTS[language];
// 컴포넌트 마운트 시 폰트 미리 로드
useEffect(() => {
const loadAllFonts = async () => {
try {
await document.fonts.ready;
// 모든 폰트를 명시적으로 로드
const allFonts = [...SIGNATURE_FONTS.korean, ...SIGNATURE_FONTS.english];
const fontLoadPromises = allFonts.map((font) => document.fonts.load(`${font.weight} 124px ${font.style}`));
await Promise.all(fontLoadPromises);
// 임시 Canvas를 그려서 폰트를 강제로 렌더링 (브라우저가 폰트를 실제로 사용하도록)
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 100;
tempCanvas.height = 100;
const tempCtx = tempCanvas.getContext("2d");
if (tempCtx) {
for (const font of allFonts) {
tempCtx.font = `${font.weight} 124px ${font.style}`;
tempCtx.fillText("테", 0, 50);
tempCtx.fillText("A", 0, 50);
}
}
// 폰트 렌더링 후 대기
await new Promise((resolve) => setTimeout(resolve, 500));
setFontsLoaded(true);
} catch (error) {
console.warn("Font preloading failed:", error);
await new Promise((resolve) => setTimeout(resolve, 1500));
setFontsLoaded(true);
}
};
loadAllFonts();
}, []);
// 서명 생성
const generateSignatures = async () => {
if (!name.trim()) return;
setIsGenerating(true);
// 폰트가 미리 로드될 때까지 대기
if (!fontsLoaded) {
await new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (fontsLoaded) {
clearInterval(checkInterval);
resolve(true);
}
}, 100);
});
}
const newSignatures: string[] = [];
// 동기적으로 하나씩 생성
for (const font of fonts) {
const canvas = document.createElement("canvas");
canvas.width = 500;
canvas.height = 200;
const ctx = canvas.getContext("2d");
if (ctx) {
// 배경 흰색
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 텍스트 스타일
ctx.fillStyle = "#000000";
let fontSize = 124;
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// 텍스트 너비 측정 및 크기 조정 (캔버스 너비의 90% 이내로)
let textWidth = ctx.measureText(name).width;
const maxWidth = canvas.width * 0.9;
while (textWidth > maxWidth && fontSize > 30) {
fontSize -= 2;
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
textWidth = ctx.measureText(name).width;
}
// 텍스트 그리기
ctx.fillText(name, canvas.width / 2, canvas.height / 2);
// 데이터 URL로 변환
newSignatures.push(canvas.toDataURL("image/png"));
}
}
setGeneratedSignatures(newSignatures);
setIsGenerating(false);
};
// 서명 선택 (더블클릭)
const handleSignatureDoubleClick = (dataUrl: string) => {
onSignatureSelect(dataUrl);
};
return (
<div className="space-y-3">
{/* 언어 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={language}
onValueChange={(value: "korean" | "english") => {
setLanguage(value);
setName(""); // 언어 변경 시 입력값 초기화
setGeneratedSignatures([]); // 생성된 서명도 초기화
}}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="korean"></SelectItem>
<SelectItem value="english"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 이름 입력 */}
<div className="space-y-2">
<Label className="text-xs">:</Label>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => {
const input = e.target.value;
// 국문일 때는 한글, 영문일 때는 영문+숫자+공백만 허용
if (language === "korean") {
// 한글만 허용 (자음, 모음, 완성된 글자)
const koreanOnly = input.replace(/[^\u3131-\u3163\uac00-\ud7a3\s]/g, "");
setName(koreanOnly);
} else {
// 영문, 숫자, 공백만 허용
const englishOnly = input.replace(/[^a-zA-Z\s]/g, "");
setName(englishOnly);
}
}}
placeholder={language === "korean" ? "홍길동" : "John Doe"}
maxLength={14}
className="h-8 flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
generateSignatures();
}
}}
/>
<Button
type="button"
size="sm"
onClick={generateSignatures}
disabled={!name.trim() || isGenerating || !fontsLoaded}
>
<Wand2 className="mr-1 h-3 w-3" />
{!fontsLoaded ? "폰트 로딩 중..." : isGenerating ? "생성 중..." : "만들기"}
</Button>
</div>
</div>
{/* 생성된 서명 목록 */}
{generatedSignatures.length > 0 && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
<ScrollArea className="h-[300px] rounded-md border bg-white">
<div className="space-y-2 p-2">
{generatedSignatures.map((signature, index) => (
<Card
key={index}
className="group hover:border-primary cursor-pointer border-2 border-transparent p-3 transition-all"
onDoubleClick={() => handleSignatureDoubleClick(signature)}
>
<div className="flex items-center justify-between">
<img
src={signature}
alt={`서명 ${index + 1}`}
className="h-auto max-h-[45px] w-auto max-w-[280px] object-contain"
/>
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
{fonts[index].name}
</p>
</div>
</Card>
))}
</div>
</ScrollArea>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Eraser, Pen } from "lucide-react";
interface SignaturePadProps {
onSignatureChange: (dataUrl: string) => void;
initialSignature?: string;
}
export function SignaturePad({ onSignatureChange, initialSignature }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasDrawn, setHasDrawn] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 초기 서명이 있으면 로드
if (initialSignature) {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
setHasDrawn(true);
};
img.src = initialSignature;
} else {
// 캔버스 초기화 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}, [initialSignature]);
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
setIsDrawing(true);
setHasDrawn(true);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineTo(x, y);
ctx.stroke();
};
const stopDrawing = () => {
if (!isDrawing) return;
setIsDrawing(false);
const canvas = canvasRef.current;
if (!canvas) return;
// 서명 이미지를 Base64 데이터로 변환하여 콜백 호출
const dataUrl = canvas.toDataURL("image/png");
onSignatureChange(dataUrl);
};
const clearSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 캔버스 클리어 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
setHasDrawn(false);
onSignatureChange("");
};
return (
<div className="space-y-2">
<Card className="overflow-hidden border-2 border-dashed border-gray-300 bg-white p-2">
<canvas
ref={canvasRef}
width={300}
height={150}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
className="cursor-crosshair touch-none"
style={{ width: "100%", height: "auto" }}
/>
</Card>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
<Pen className="mr-1 inline h-3 w-3" />
</p>
<Button type="button" variant="outline" size="sm" onClick={clearSignature} disabled={!hasDrawn}>
<Eraser className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface Template {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
is_system: string;
}
export function TemplatePalette() {
const { applyTemplate } = useReportDesigner();
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { toast } = useToast();
const fetchTemplates = async () => {
setIsLoading(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
}
} catch (error) {
console.error("템플릿 조회 실패:", error);
toast({
title: "오류",
description: "템플릿 목록을 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTemplates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleApplyTemplate = async (templateId: string) => {
await applyTemplate(templateId);
};
const handleDeleteTemplate = async (templateId: string, templateName: string) => {
if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) {
return;
}
setDeletingId(templateId);
try {
const response = await reportApi.deleteTemplate(templateId);
if (response.success) {
toast({
title: "성공",
description: "템플릿이 삭제되었습니다.",
});
fetchTemplates();
}
} catch (error: any) {
toast({
title: "오류",
description: error.response?.data?.message || "템플릿 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setDeletingId(null);
}
};
return (
<div className="space-y-4">
{/* 시스템 템플릿 (DB에서 조회) */}
{systemTemplates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
</div>
{systemTemplates.map((template) => (
<Button
key={template.template_id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>{template.template_name_kor}</span>
</Button>
))}
</div>
)}
{/* 사용자 정의 템플릿 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
) : customTemplates.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400"> 릿 </p>
) : (
customTemplates.map((template) => (
<div key={template.template_id} className="group relative">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 pr-8 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>📄</span>
<span className="truncate">{template.template_name_kor}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(template.template_id, template.template_name_kor);
}}
disabled={deletingId === template.template_id}
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
{deletingId === template.template_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3 text-red-500" />
)}
</Button>
</div>
))
)}
</div>
</div>
);
}

View File

@ -69,7 +69,7 @@ function Accordion({
return ( return (
<AccordionContext.Provider value={contextValue}> <AccordionContext.Provider value={contextValue}>
<div className={cn("space-y-2", className)} onClick={onClick} {...props}> <div className={cn(className)} onClick={onClick} {...props}>
{children} {children}
</div> </div>
</AccordionContext.Provider> </AccordionContext.Provider>
@ -83,8 +83,33 @@ interface AccordionItemProps {
} }
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) { function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const handleClick = (e: React.MouseEvent) => {
if (!context?.onValueChange) return;
const target = e.target as HTMLElement;
if (target.closest('button[type="button"]') && !target.closest(".accordion-trigger")) {
return;
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== value) : [...currentValue, value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : value;
context.onValueChange(newValue);
}
};
return ( return (
<div className={cn("rounded-md border", className)} data-value={value} {...props}> <div className={cn("cursor-pointer", className)} data-value={value} onClick={handleClick} {...props}>
{children} {children}
</div> </div>
); );
@ -124,7 +149,7 @@ function AccordionTrigger({ className, children, ...props }: AccordionTriggerPro
return ( return (
<button <button
className={cn( className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none", "accordion-trigger flex w-full cursor-pointer items-center justify-between p-4 text-left font-medium transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}
@ -145,6 +170,7 @@ interface AccordionContentProps {
function AccordionContent({ className, children, ...props }: AccordionContentProps) { function AccordionContent({ className, children, ...props }: AccordionContentProps) {
const context = React.useContext(AccordionContext); const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext); const parent = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
if (!context || !parent) { if (!context || !parent) {
throw new Error("AccordionContent must be used within AccordionItem"); throw new Error("AccordionContent must be used within AccordionItem");
@ -155,11 +181,18 @@ function AccordionContent({ className, children, ...props }: AccordionContentPro
? Array.isArray(context.value) && context.value.includes(parent.value) ? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value; : context.value === parent.value;
if (!isOpen) return null;
return ( return (
<div className={cn("px-4 pb-4 text-sm text-muted-foreground", className)} {...props}> <div
{children} ref={contentRef}
className={cn(
"text-muted-foreground overflow-hidden text-sm transition-all duration-300 ease-in-out",
isOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<div className="cursor-default">{children}</div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { ReportMaster, GetReportsParams } from "@/types/report";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
export function useReportList() {
const [reports, setReports] = useState<ReportMaster[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [isLoading, setIsLoading] = useState(false);
const [searchText, setSearchText] = useState("");
const { toast } = useToast();
const fetchReports = async () => {
setIsLoading(true);
try {
const params: GetReportsParams = {
page,
limit,
searchText,
useYn: "Y",
sortBy: "created_at",
sortOrder: "DESC",
};
const response = await reportApi.getReports(params);
if (response.success && response.data) {
setReports(response.data.items);
setTotal(response.data.total);
}
} catch (error: any) {
console.error("리포트 목록 조회 에러:", error);
toast({
title: "오류",
description: error.message || "리포트 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, searchText]);
const handleSearch = (text: string) => {
setSearchText(text);
setPage(1);
};
return {
reports,
total,
page,
limit,
isLoading,
refetch: fetchReports,
setPage,
handleSearch,
};
}

View File

@ -12,12 +12,12 @@ const getApiBaseUrl = (): string => {
const currentHost = window.location.hostname; const currentHost = window.location.hostname;
const currentPort = window.location.port; const currentPort = window.location.port;
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080 // 🎯 로컬 개발환경: Next.js 프록시 사용 (대용량 요청 안정성)
if ( if (
(currentHost === "localhost" || currentHost === "127.0.0.1") && (currentHost === "localhost" || currentHost === "127.0.0.1") &&
(currentPort === "9771" || currentPort === "3000") (currentPort === "9771" || currentPort === "3000")
) { ) {
return "http://localhost:8080/api"; return "/api"; // 프록시 사용
} }
} }
@ -27,6 +27,22 @@ const getApiBaseUrl = (): string => {
export const API_BASE_URL = getApiBaseUrl(); export const API_BASE_URL = getApiBaseUrl();
// 이미지 URL을 완전한 URL로 변환하는 함수
export const getFullImageUrl = (imagePath: string): string => {
// 이미 전체 URL인 경우 그대로 반환
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath;
}
// /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가
if (imagePath.startsWith("/uploads")) {
const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거
return `${baseUrl}${imagePath}`;
}
return imagePath;
};
// JWT 토큰 관리 유틸리티 // JWT 토큰 관리 유틸리티
const TokenManager = { const TokenManager = {
getToken: (): string | null => { getToken: (): string | null => {

View File

@ -70,12 +70,76 @@ export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
export interface SendMailDto { export interface SendMailDto {
accountId: string; accountId: string;
templateId?: string; templateId?: string;
to: string[]; // 수신자 이메일 배열 to: string[]; // 받는 사람
cc?: string[]; // 참조 (Carbon Copy)
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
subject: string; subject: string;
variables?: Record<string, string>; // 템플릿 변수 치환 variables?: Record<string, string>; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시 customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
} }
// ============================================
// 발송 이력 타입
// ============================================
export interface AttachmentInfo {
filename: string;
originalName: string;
size: number;
path: string;
mimetype: string;
}
export interface SentMailHistory {
id: string;
accountId: string;
accountName: string;
accountEmail: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
htmlContent: string;
templateId?: string;
templateName?: string;
attachments?: AttachmentInfo[];
sentAt: string;
status: 'success' | 'failed';
messageId?: string;
errorMessage?: string;
accepted?: string[];
rejected?: string[];
}
export interface SentMailListQuery {
page?: number;
limit?: number;
searchTerm?: string;
status?: 'success' | 'failed' | 'all';
accountId?: string;
startDate?: string;
endDate?: string;
sortBy?: 'sentAt' | 'subject';
sortOrder?: 'asc' | 'desc';
}
export interface SentMailListResponse {
items: SentMailHistory[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface MailStatistics {
totalSent: number;
successCount: number;
failedCount: number;
todayCount: number;
thisMonthCount: number;
successRate: number;
}
export interface MailSendResult { export interface MailSendResult {
success: boolean; success: boolean;
messageId?: string; messageId?: string;
@ -96,7 +160,7 @@ async function fetchApi<T>(
try { try {
const response = await apiClient({ const response = await apiClient({
url: `/mail${endpoint}`, url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
method, method,
data, data,
}); });
@ -124,14 +188,14 @@ async function fetchApi<T>(
* *
*/ */
export async function getMailAccounts(): Promise<MailAccount[]> { export async function getMailAccounts(): Promise<MailAccount[]> {
return fetchApi<MailAccount[]>('/accounts'); return fetchApi<MailAccount[]>('/mail/accounts');
} }
/** /**
* *
*/ */
export async function getMailAccount(id: string): Promise<MailAccount> { export async function getMailAccount(id: string): Promise<MailAccount> {
return fetchApi<MailAccount>(`/accounts/${id}`); return fetchApi<MailAccount>(`/mail/accounts/${id}`);
} }
/** /**
@ -140,7 +204,7 @@ export async function getMailAccount(id: string): Promise<MailAccount> {
export async function createMailAccount( export async function createMailAccount(
data: CreateMailAccountDto data: CreateMailAccountDto
): Promise<MailAccount> { ): Promise<MailAccount> {
return fetchApi<MailAccount>('/accounts', { return fetchApi<MailAccount>('/mail/accounts', {
method: 'POST', method: 'POST',
data, data,
}); });
@ -153,7 +217,7 @@ export async function updateMailAccount(
id: string, id: string,
data: UpdateMailAccountDto data: UpdateMailAccountDto
): Promise<MailAccount> { ): Promise<MailAccount> {
return fetchApi<MailAccount>(`/accounts/${id}`, { return fetchApi<MailAccount>(`/mail/accounts/${id}`, {
method: 'PUT', method: 'PUT',
data, data,
}); });
@ -163,7 +227,7 @@ export async function updateMailAccount(
* *
*/ */
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> { export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
return fetchApi<{ success: boolean }>(`/accounts/${id}`, { return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
@ -172,7 +236,7 @@ export async function deleteMailAccount(id: string): Promise<{ success: boolean
* SMTP * SMTP
*/ */
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> { export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
return fetchApi<{ success: boolean; message: string }>(`/accounts/${id}/test-connection`, { return fetchApi<{ success: boolean; message: string }>(`/mail/accounts/${id}/test-connection`, {
method: 'POST', method: 'POST',
}); });
} }
@ -185,7 +249,7 @@ export async function testMailConnection(id: string): Promise<{
message: string; message: string;
}> { }> {
return fetchApi<{ success: boolean; message: string }>( return fetchApi<{ success: boolean; message: string }>(
`/accounts/${id}/test-connection`, `/mail/accounts/${id}/test-connection`,
{ {
method: 'POST', method: 'POST',
} }
@ -200,14 +264,14 @@ export async function testMailConnection(id: string): Promise<{
* 릿 * 릿
*/ */
export async function getMailTemplates(): Promise<MailTemplate[]> { export async function getMailTemplates(): Promise<MailTemplate[]> {
return fetchApi<MailTemplate[]>('/templates-file'); return fetchApi<MailTemplate[]>('/mail/templates-file');
} }
/** /**
* 릿 * 릿
*/ */
export async function getMailTemplate(id: string): Promise<MailTemplate> { export async function getMailTemplate(id: string): Promise<MailTemplate> {
return fetchApi<MailTemplate>(`/templates-file/${id}`); return fetchApi<MailTemplate>(`/mail/templates-file/${id}`);
} }
/** /**
@ -216,7 +280,7 @@ export async function getMailTemplate(id: string): Promise<MailTemplate> {
export async function createMailTemplate( export async function createMailTemplate(
data: CreateMailTemplateDto data: CreateMailTemplateDto
): Promise<MailTemplate> { ): Promise<MailTemplate> {
return fetchApi<MailTemplate>('/templates-file', { return fetchApi<MailTemplate>('/mail/templates-file', {
method: 'POST', method: 'POST',
data, data,
}); });
@ -229,7 +293,7 @@ export async function updateMailTemplate(
id: string, id: string,
data: UpdateMailTemplateDto data: UpdateMailTemplateDto
): Promise<MailTemplate> { ): Promise<MailTemplate> {
return fetchApi<MailTemplate>(`/templates-file/${id}`, { return fetchApi<MailTemplate>(`/mail/templates-file/${id}`, {
method: 'PUT', method: 'PUT',
data, data,
}); });
@ -239,7 +303,7 @@ export async function updateMailTemplate(
* 릿 * 릿
*/ */
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> { export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, { return fetchApi<{ success: boolean }>(`/mail/templates-file/${id}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
@ -251,7 +315,7 @@ export async function previewMailTemplate(
id: string, id: string,
sampleData?: Record<string, string> sampleData?: Record<string, string>
): Promise<{ html: string }> { ): Promise<{ html: string }> {
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, { return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
method: 'POST', method: 'POST',
data: { sampleData }, data: { sampleData },
}); });
@ -265,7 +329,7 @@ export async function previewMailTemplate(
* ( ) * ( )
*/ */
export async function sendMail(data: SendMailDto): Promise<MailSendResult> { export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
return fetchApi<MailSendResult>('/send/simple', { return fetchApi<MailSendResult>('/mail/send/simple', {
method: 'POST', method: 'POST',
data, data,
}); });
@ -407,6 +471,15 @@ export async function getReceivedMails(
return fetchApi<ReceivedMail[]>(`/mail/receive/${accountId}?limit=${limit}`); return fetchApi<ReceivedMail[]>(`/mail/receive/${accountId}?limit=${limit}`);
} }
/**
* ()
*/
export async function getTodayReceivedCount(accountId?: string): Promise<number> {
const params = accountId ? `?accountId=${accountId}` : '';
const response = await fetchApi<{ count: number }>(`/mail/receive/today-count${params}`);
return response.count;
}
/** /**
* *
*/ */
@ -439,3 +512,52 @@ export async function testImapConnection(
method: 'POST', method: 'POST',
}); });
} }
// ============================================
// 발송 이력 API
// ============================================
/**
*
*/
export async function getSentMailList(
query: SentMailListQuery = {}
): Promise<SentMailListResponse> {
const params = new URLSearchParams();
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
if (query.searchTerm) params.append('searchTerm', query.searchTerm);
if (query.status && query.status !== 'all') params.append('status', query.status);
if (query.accountId) params.append('accountId', query.accountId);
if (query.startDate) params.append('startDate', query.startDate);
if (query.endDate) params.append('endDate', query.endDate);
if (query.sortBy) params.append('sortBy', query.sortBy);
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
return fetchApi(`/mail/sent?${params.toString()}`);
}
/**
*
*/
export async function getSentMailById(id: string): Promise<SentMailHistory> {
return fetchApi(`/mail/sent/${id}`);
}
/**
*
*/
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
return fetchApi(`/mail/sent/${id}`, {
method: 'DELETE',
});
}
/**
*
*/
export async function getMailStatistics(accountId?: string): Promise<MailStatistics> {
const params = accountId ? `?accountId=${accountId}` : '';
return fetchApi(`/mail/sent/statistics${params}`);
}

View File

@ -0,0 +1,225 @@
import { apiClient } from "./client";
import {
ReportMaster,
ReportDetail,
GetReportsParams,
GetReportsResponse,
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
GetTemplatesResponse,
CreateTemplateRequest,
ReportLayout,
} from "@/types/report";
const BASE_URL = "/admin/reports";
export const reportApi = {
// 리포트 목록 조회
getReports: async (params: GetReportsParams) => {
const response = await apiClient.get<{
success: boolean;
data: GetReportsResponse;
}>(BASE_URL, { params });
return response.data;
},
// 리포트 상세 조회
getReportById: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportDetail;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 생성
createReport: async (data: CreateReportRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(BASE_URL, data);
return response.data;
},
// 리포트 수정
updateReport: async (reportId: string, data: UpdateReportRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`, data);
return response.data;
},
// 리포트 삭제
deleteReport: async (reportId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 복사
copyReport: async (reportId: string) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(`${BASE_URL}/${reportId}/copy`);
return response.data;
},
// 레이아웃 조회
getLayout: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportLayout;
}>(`${BASE_URL}/${reportId}/layout`);
return response.data;
},
// 레이아웃 저장
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}/layout`, data);
return response.data;
},
// 템플릿 목록 조회
getTemplates: async () => {
const response = await apiClient.get<{
success: boolean;
data: GetTemplatesResponse;
}>(`${BASE_URL}/templates`);
return response.data;
},
// 템플릿 생성
createTemplate: async (data: CreateTemplateRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/templates`, data);
return response.data;
},
// 템플릿 삭제
deleteTemplate: async (templateId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/templates/${templateId}`);
return response.data;
},
// 쿼리 실행
executeQuery: async (
reportId: string,
queryId: string,
parameters: Record<string, any>,
sqlQuery?: string,
externalConnectionId?: number | null,
) => {
const response = await apiClient.post<{
success: boolean;
data: {
fields: string[];
rows: any[];
};
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, {
parameters,
sqlQuery,
externalConnectionId,
});
return response.data;
},
// 외부 DB 연결 목록 조회
getExternalConnections: async () => {
const response = await apiClient.get<{
success: boolean;
data: any[];
}>(`${BASE_URL}/external-connections`);
return response.data;
},
// 현재 리포트를 템플릿으로 저장
saveAsTemplate: async (
reportId: string,
data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
},
) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/${reportId}/save-as-template`, data);
return response.data;
},
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
createTemplateFromLayout: async (data: {
templateNameKor: string;
templateNameEng?: string;
templateType?: string;
description?: string;
layoutConfig: {
width: number;
height: number;
orientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: any[];
};
defaultQueries?: Array<{
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
displayOrder?: number;
}>;
}) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/templates/create-from-layout`, data);
return response.data;
},
// 이미지 업로드
uploadImage: async (file: File) => {
const formData = new FormData();
formData.append("image", file);
const response = await apiClient.post<{
success: boolean;
data: {
fileName: string;
fileUrl: string;
originalName: string;
size: number;
mimeType: string;
};
message?: string;
}>(`${BASE_URL}/upload-image`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
},
};

View File

@ -1,389 +1,155 @@
import { Position, Size } from "@/types/screen"; import type { ComponentConfig, GridConfig } from "@/types/report";
import { GridSettings } from "@/types/screen-management";
export interface GridInfo { /**
columnWidth: number; *
totalWidth: number; */
totalHeight: number; export function pixelToGrid(pixel: number, cellSize: number): number {
return Math.round(pixel / cellSize);
} }
/** /**
* *
*/ */
export function calculateGridInfo( export function gridToPixel(grid: number, cellSize: number): number {
containerWidth: number, return grid * cellSize;
containerHeight: number, }
gridSettings: GridSettings,
): GridInfo {
const { columns, gap, padding } = gridSettings;
// 사용 가능한 너비 계산 (패딩 제외) /**
const availableWidth = containerWidth - padding * 2; * /
*/
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
if (!gridConfig.snapToGrid) {
return component;
}
// 격자 간격을 고려한 컬럼 너비 계산 // 픽셀 좌표를 그리드 좌표로 변환
const totalGaps = (columns - 1) * gap; const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
const columnWidth = (availableWidth - totalGaps) / columns; const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
// 그리드 좌표를 다시 픽셀로 변환
return { return {
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시 ...component,
totalWidth: containerWidth, gridX,
totalHeight: containerHeight, gridY,
gridWidth,
gridHeight,
x: gridToPixel(gridX, gridConfig.cellWidth),
y: gridToPixel(gridY, gridConfig.cellHeight),
width: gridToPixel(gridWidth, gridConfig.cellWidth),
height: gridToPixel(gridHeight, gridConfig.cellHeight),
}; };
} }
/** /**
* *
*
*/ */
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position { export function detectGridCollision(
if (!gridSettings.snapToGrid) { component: ComponentConfig,
return position; otherComponents: ComponentConfig[],
} gridConfig: GridConfig,
const { columnWidth } = gridInfo;
const { gap, padding } = gridSettings;
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
const relativeY = position.y - padding;
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
const gridX = Math.round(relativeX / cellWidth);
const gridY = Math.round(relativeY / cellHeight);
// 실제 픽셀 위치로 변환
const snappedX = Math.max(padding, padding + gridX * cellWidth);
const snappedY = Math.max(padding, padding + gridY * cellHeight);
return {
x: snappedX,
y: snappedY,
z: position.z,
};
}
/**
*
*/
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
if (!gridSettings.snapToGrid) {
return size;
}
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 격자 단위로 너비 계산
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
};
}
/**
*
*/
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
}
/**
*
*/
export function generateGridLines(
containerWidth: number,
containerHeight: number,
gridSettings: GridSettings,
): {
verticalLines: number[];
horizontalLines: number[];
} {
const { columns, gap, padding } = gridSettings;
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
const { columnWidth } = gridInfo;
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2);
// 세로 격자선
const verticalLines: number[] = [];
for (let i = 0; i <= columns; i++) {
const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
}
// 가로 격자선
const horizontalLines: number[] = [];
for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y);
}
return {
verticalLines,
horizontalLines,
};
}
/**
*
*/
export function isOnGridBoundary(
position: Position,
size: Size,
gridInfo: GridInfo,
gridSettings: GridSettings,
tolerance: number = 5,
): boolean { ): boolean {
const snappedPos = snapToGrid(position, gridInfo, gridSettings); const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings); const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
const positionMatch = for (const other of otherComponents) {
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance; if (other.id === component.id) continue;
const sizeMatch = const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance; const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
return positionMatch && sizeMatch; // AABB (Axis-Aligned Bounding Box) 충돌 감지
} const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
/** if (xOverlap && yOverlap) {
* return true;
*/ }
export function alignGroupChildrenToGrid(
children: any[],
groupPosition: Position,
gridInfo: GridInfo,
gridSettings: GridSettings,
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedChild = {
...child,
position: {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
width: snappedWidth,
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
/**
*
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
} }
console.log("📏 calculateOptimalGroupSize 시작:", { return false;
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
minY: Math.min(acc.minY, child.position.y),
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
} }
/** /**
* * /
*/ */
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] { export function calculateGridDimensions(
if (!gridSettings.snapToGrid || children.length === 0) return children; pageWidth: number,
pageHeight: number,
console.log("🔄 normalizeGroupChildPositions 시작:", { cellWidth: number,
childrenCount: children.length, cellHeight: number,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })), ): { rows: number; columns: number } {
}); return {
columns: Math.floor(pageWidth / cellWidth),
// 모든 자식의 최소 위치 찾기 rows: Math.floor(pageHeight / cellHeight),
const minX = Math.min(...children.map((child) => child.position.x)); };
const minY = Math.min(...children.map((child) => child.position.y)); }
console.log("📍 최소 위치:", { minX, minY }); /**
*
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백) */
const padding = 16; export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
const startX = padding; const cellWidth = 20;
const startY = padding; const cellHeight = 20;
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
const normalizedChildren = children.map((child) => ({
...child, return {
position: { cellWidth,
x: child.position.x - minX + startX, cellHeight,
y: child.position.y - minY + startY, rows,
z: child.position.z || 1, columns,
}, visible: true,
})); snapToGrid: true,
gridColor: "#e5e7eb",
console.log("✅ 정규화 완료:", { gridOpacity: 0.5,
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })), };
}); }
return normalizedChildren; /**
*
*/
export function isWithinPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
): boolean {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right;
const maxY = pageHeight - margins.bottom;
return (
component.x >= minX &&
component.y >= minY &&
component.x + component.width <= maxX &&
component.y + component.height <= maxY
);
}
/**
*
*/
export function constrainToPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
): ComponentConfig {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right - component.width;
const maxY = pageHeight - margins.bottom - component.height;
return {
...component,
x: Math.max(minX, Math.min(maxX, component.x)),
y: Math.max(minY, Math.min(maxY, component.y)),
};
} }

View File

@ -39,21 +39,26 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"next": "15.4.4", "next": "15.4.4",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0", "react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0", "react-window": "^2.1.0",
"reactflow": "^11.10.4", "reactflow": "^11.10.4",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"sheetjs-style": "^0.15.8", "sheetjs-style": "^0.15.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
@ -64,6 +69,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -89,6 +95,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": { "node_modules/@date-fns/tz": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@ -2409,6 +2424,24 @@
} }
} }
}, },
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -3103,7 +3136,7 @@
"version": "20.19.17", "version": "20.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -3143,6 +3176,13 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.1", "version": "8.44.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
@ -4870,6 +4910,17 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -4883,6 +4934,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx-preview": { "node_modules/docx-preview": {
"version": "0.3.6", "version": "0.3.6",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz", "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz",
@ -4892,6 +4960,39 @@
"jszip": ">=3.0.0" "jszip": ">=3.0.0"
} }
}, },
"node_modules/docx/node_modules/@types/node": {
"version": "24.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz",
"integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.13.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -5688,7 +5789,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@ -6169,6 +6269,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6181,6 +6291,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7234,6 +7353,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8055,6 +8180,45 @@
"react": ">=16.8.0" "react": ">=16.8.0"
} }
}, },
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -8176,6 +8340,16 @@
} }
} }
}, },
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -8303,6 +8477,15 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -8490,6 +8673,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@ -9297,7 +9486,7 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
@ -9425,6 +9614,19 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -9609,6 +9811,24 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "10.1.1", "version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",

View File

@ -47,6 +47,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"isomorphic-dompurify": "^2.28.0", "isomorphic-dompurify": "^2.28.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@ -54,15 +55,19 @@
"next": "15.4.4", "next": "15.4.4",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0", "react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0", "react-window": "^2.1.0",
"reactflow": "^11.10.4", "reactflow": "^11.10.4",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"sheetjs-style": "^0.15.8", "sheetjs-style": "^0.15.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
@ -73,6 +78,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",

263
frontend/types/report.ts Normal file
View File

@ -0,0 +1,263 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
template_type: string;
is_system: string;
thumbnail_url: string | null;
description: string | null;
layout_config: string | null;
default_queries: string | null;
use_yn: string;
sort_order: number;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
report_type: string;
company_code: string | null;
description: string | null;
use_yn: string;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
canvas_width: number;
canvas_height: number;
page_orientation: string;
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
components: ComponentConfig[];
pages?: ReportPage[]; // 새 페이지 구조 (옵셔널, 하위 호환성)
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 쿼리
export interface ReportQuery {
query_id: string;
report_id: string;
query_name: string;
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[];
external_connection_id: number | null; // 외부 DB 연결 ID
display_order: number;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 외부 DB 연결 (간단한 버전)
export interface ExternalConnection {
id: number;
connection_name: string;
db_type: string;
description?: string;
is_active: string;
}
// 그리드 설정
export interface GridConfig {
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellHeight)
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
// 페이지 설정
export interface ReportPage {
page_id: string;
page_name: string;
page_order: number;
width: number; // mm
height: number; // mm
orientation: "portrait" | "landscape";
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
background_color: string;
gridConfig?: GridConfig; // 그리드 설정 (옵셔널)
components: ComponentConfig[];
}
// 레이아웃 설정 (페이지 기반)
export interface ReportLayoutConfig {
pages: ReportPage[];
}
// 컴포넌트 설정
export interface ComponentConfig {
id: string;
type: string; // "text", "label", "table", "image", "divider", "signature", "stamp"
x: number;
y: number;
width: number;
height: number;
zIndex: number;
// 그리드 좌표 (옵셔널)
gridX?: number; // 시작 열 (0부터 시작)
gridY?: number; // 시작 행 (0부터 시작)
gridWidth?: number; // 차지하는 열 수
gridHeight?: number; // 차지하는 행 수
fontSize?: number;
fontFamily?: string;
fontWeight?: string;
fontColor?: string;
backgroundColor?: string;
borderWidth?: number;
borderColor?: string;
borderRadius?: number;
textAlign?: string;
padding?: number;
queryId?: string;
fieldName?: string;
defaultValue?: string;
format?: string;
visible?: boolean;
printable?: boolean;
conditional?: string;
locked?: boolean; // 잠금 여부 (편집/이동/삭제 방지)
groupId?: string; // 그룹 ID (같은 그룹 ID를 가진 컴포넌트는 함께 움직임)
// 이미지 전용
imageUrl?: string; // 이미지 URL 또는 업로드된 파일 경로
imageFile?: File; // 업로드된 이미지 파일 (클라이언트 측에서만 사용)
objectFit?: "contain" | "cover" | "fill" | "none"; // 이미지 맞춤 방식
// 구분선 전용
orientation?: "horizontal" | "vertical"; // 구분선 방향
lineStyle?: "solid" | "dashed" | "dotted" | "double"; // 선 스타일
lineWidth?: number; // 구분선 두께 (borderWidth와 별도)
lineColor?: string; // 구분선 색상 (borderColor와 별도)
// 서명/도장 전용
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
labelText?: string; // 커스텀 레이블 텍스트
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
showUnderline?: boolean; // 서명란 밑줄 표시 여부
personName?: string; // 도장란 이름 (예: "홍길동")
// 테이블 전용
tableColumns?: Array<{
field: string; // 필드명
header: string; // 헤더 표시명
width?: number; // 컬럼 너비 (px)
align?: "left" | "center" | "right"; // 정렬
}>;
headerBackgroundColor?: string; // 헤더 배경색
headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px)
}
// 리포트 상세
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
layoutConfig: ReportLayoutConfig; // 페이지 기반 구조
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number;
}>;
// 하위 호환성 (deprecated)
canvasWidth?: number;
canvasHeight?: number;
pageOrientation?: string;
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
components?: ComponentConfig[];
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: any;
defaultQueries?: any;
}

View File

@ -17,27 +17,39 @@ echo "======================================"
# Git 최신 코드 가져오기 # Git 최신 코드 가져오기
echo "" echo ""
echo "[1/5] Git 최신 코드 가져오기..." echo "[1/6] Git 최신 코드 가져오기..."
git pull origin main git pull origin main
# 호스트 디렉토리 준비
echo ""
echo "[2/6] 호스트 디렉토리 준비..."
mkdir -p /home/vexplor/backend_data/data/mail-sent
mkdir -p /home/vexplor/backend_data/uploads/mail-attachments
mkdir -p /home/vexplor/backend_data/uploads/mail-templates
mkdir -p /home/vexplor/backend_data/uploads/mail-accounts
mkdir -p /home/vexplor/frontend_data
chmod -R 755 /home/vexplor/backend_data
chmod -R 755 /home/vexplor/frontend_data
echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)"
# 기존 컨테이너 중지 및 제거 # 기존 컨테이너 중지 및 제거
echo "" echo ""
echo "[2/5] 기존 컨테이너 중지..." echo "[3/6] 기존 컨테이너 중지..."
docker-compose -f "$COMPOSE_FILE" down docker-compose -f "$COMPOSE_FILE" down
# 오래된 이미지 정리 # 오래된 이미지 정리
echo "" echo ""
echo "[3/5] Docker 이미지 정리..." echo "[4/6] Docker 이미지 정리..."
docker image prune -f docker image prune -f
# 새로운 이미지 빌드 # 새로운 이미지 빌드
echo "" echo ""
echo "[4/5] Docker 이미지 빌드..." echo "[5/6] Docker 이미지 빌드..."
docker-compose -f "$COMPOSE_FILE" build --no-cache docker-compose -f "$COMPOSE_FILE" build --no-cache
# 컨테이너 실행 # 컨테이너 실행
echo "" echo ""
echo "[5/5] 컨테이너 실행..." echo "[6/6] 컨테이너 실행..."
docker-compose -f "$COMPOSE_FILE" up -d docker-compose -f "$COMPOSE_FILE" up -d
# 배포 완료 # 배포 완료

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