메일관리 기능 구현 완료

This commit is contained in:
leeheejin 2025-10-13 15:17:34 +09:00
parent b4c5be1f17
commit 95c98cbda3
40 changed files with 2227 additions and 4150 deletions

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": []
}

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

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": []
}

File diff suppressed because one or more lines are too long

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

@ -73,8 +73,8 @@ 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" }));
// 정적 파일 서빙 (업로드된 파일들) // 정적 파일 서빙 (업로드된 파일들)
app.use( app.use(
@ -165,6 +165,17 @@ 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/mail/sent", mailSentHistoryRoutes); // 발송 이력
app.use("/api/screen", screenStandardRoutes); app.use("/api/screen", screenStandardRoutes);

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

@ -19,6 +19,9 @@ export class MailSendSimpleController {
// FormData에서 JSON 문자열 파싱 // FormData에서 JSON 문자열 파싱
const accountId = req.body.accountId; const accountId = req.body.accountId;
const templateId = req.body.templateId; 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 to = req.body.to ? JSON.parse(req.body.to) : [];
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined; const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined; const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
@ -90,6 +93,7 @@ export class MailSendSimpleController {
const result = await mailSendSimpleService.sendMail({ const result = await mailSendSimpleService.sendMail({
accountId, accountId,
templateId, templateId,
modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
to, to,
cc, cc,
bcc, bcc,

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

@ -109,7 +109,7 @@ export class MailReceiveBasicService {
tls: true, tls: true,
}; };
console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`); // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
@ -117,26 +117,26 @@ export class MailReceiveBasicService {
// 30초 타임아웃 설정 // 30초 타임아웃 설정
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.error('❌ IMAP 연결 타임아웃 (30초)'); // console.error('❌ IMAP 연결 타임아웃 (30초)');
imap.end(); imap.end();
reject(new Error('IMAP 연결 타임아웃')); reject(new Error('IMAP 연결 타임아웃'));
}, 30000); }, 30000);
imap.once('ready', () => { imap.once('ready', () => {
console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout); clearTimeout(timeout);
imap.openBox('INBOX', true, (err: any, box: any) => { imap.openBox('INBOX', true, (err: any, box: any) => {
if (err) { if (err) {
console.error('❌ INBOX 열기 실패:', err); // console.error('❌ INBOX 열기 실패:', err);
imap.end(); imap.end();
return reject(err); return reject(err);
} }
console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total; const totalMessages = box.messages.total;
if (totalMessages === 0) { if (totalMessages === 0) {
console.log('📭 메일함이 비어있습니다'); // console.log('📭 메일함이 비어있습니다');
imap.end(); imap.end();
return resolve([]); return resolve([]);
} }
@ -145,19 +145,19 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1); const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages; const end = totalMessages;
console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, { const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'], bodies: ['HEADER', 'TEXT'],
struct: true, struct: true,
}); });
console.log(`📦 fetch 객체 생성 완료`); // console.log(`📦 fetch 객체 생성 완료`);
let processedCount = 0; let processedCount = 0;
const totalToProcess = end - start + 1; const totalToProcess = end - start + 1;
fetch.on('message', (msg: any, seqno: any) => { fetch.on('message', (msg: any, seqno: any) => {
console.log(`📬 메일 #${seqno} 처리 시작`); // console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = ''; let header: string = '';
let body: string = ''; let body: string = '';
let attributes: any = null; let attributes: any = null;
@ -207,10 +207,10 @@ export class MailReceiveBasicService {
}; };
mails.push(mail); mails.push(mail);
console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
processedCount++; processedCount++;
} catch (parseError) { } catch (parseError) {
console.error(`메일 #${seqno} 파싱 오류:`, parseError); // console.error(`메일 #${seqno} 파싱 오류:`, parseError);
processedCount++; processedCount++;
} }
} }
@ -219,24 +219,24 @@ export class MailReceiveBasicService {
}); });
fetch.once('error', (fetchErr: any) => { fetch.once('error', (fetchErr: any) => {
console.error('❌ 메일 fetch 에러:', fetchErr); // console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end(); imap.end();
reject(fetchErr); reject(fetchErr);
}); });
fetch.once('end', () => { fetch.once('end', () => {
console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
// 모든 메일 처리가 완료될 때까지 대기 // 모든 메일 처리가 완료될 때까지 대기
const checkComplete = setInterval(() => { const checkComplete = setInterval(() => {
console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}`); // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
if (processedCount >= totalToProcess) { if (processedCount >= totalToProcess) {
clearInterval(checkComplete); clearInterval(checkComplete);
console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}`); // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
imap.end(); imap.end();
// 최신 메일이 위로 오도록 정렬 // 최신 메일이 위로 오도록 정렬
mails.sort((a, b) => b.date.getTime() - a.date.getTime()); mails.sort((a, b) => b.date.getTime() - a.date.getTime());
console.log(`📤 메일 목록 반환: ${mails.length}`); // console.log(`📤 메일 목록 반환: ${mails.length}개`);
resolve(mails); resolve(mails);
} }
}, 100); }, 100);
@ -244,7 +244,7 @@ export class MailReceiveBasicService {
// 최대 10초 대기 // 최대 10초 대기
setTimeout(() => { setTimeout(() => {
clearInterval(checkComplete); clearInterval(checkComplete);
console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}`); // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
imap.end(); imap.end();
mails.sort((a, b) => b.date.getTime() - a.date.getTime()); mails.sort((a, b) => b.date.getTime() - a.date.getTime());
resolve(mails); resolve(mails);
@ -254,16 +254,16 @@ export class MailReceiveBasicService {
}); });
imap.once('error', (imapErr: any) => { imap.once('error', (imapErr: any) => {
console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr); // console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
clearTimeout(timeout); clearTimeout(timeout);
reject(imapErr); reject(imapErr);
}); });
imap.once('end', () => { imap.once('end', () => {
console.log('🔌 IMAP 연결 종료'); // console.log('🔌 IMAP 연결 종료');
}); });
console.log('🔗 IMAP.connect() 호출...'); // console.log('🔗 IMAP.connect() 호출...');
imap.connect(); imap.connect();
}); });
} }
@ -311,22 +311,36 @@ export class MailReceiveBasicService {
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) => {
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on('body', (stream: any, info: any) => { msg.on('body', (stream: any, info: any) => {
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
let buffer = ''; let buffer = '';
stream.on('data', (chunk: any) => { stream.on('data', (chunk: any) => {
buffer += chunk.toString('utf8'); buffer += chunk.toString('utf8');
}); });
stream.once('end', async () => { stream.once('end', async () => {
console.log(`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${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) ? parsed.from[0] : parsed.from;
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
@ -353,21 +367,48 @@ export class MailReceiveBasicService {
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);
}); });
}); });
}); });
@ -492,6 +533,43 @@ export class MailReceiveBasicService {
} }
} }
/**
* ()
*/
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;
}
}
/** /**
* *
*/ */
@ -533,19 +611,26 @@ export class MailReceiveBasicService {
}); });
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) => {
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on('body', (stream: any, info: any) => { msg.on('body', (stream: any, info: any) => {
console.log(`📎 메일 본문 스트림 시작`);
let buffer = ''; let buffer = '';
stream.on('data', (chunk: any) => { stream.on('data', (chunk: any) => {
buffer += chunk.toString('utf8'); buffer += chunk.toString('utf8');
}); });
stream.once('end', async () => { stream.once('end', async () => {
console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${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(
@ -557,28 +642,51 @@ 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);
}); });
}); });
}); });

View File

@ -12,6 +12,7 @@ import { mailSentHistoryService } from './mailSentHistoryService';
export interface SendMailRequest { export interface SendMailRequest {
accountId: string; accountId: string;
templateId?: string; templateId?: string;
modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
to: string[]; // 받는 사람 to: string[]; // 받는 사람
cc?: string[]; // 참조 (Carbon Copy) cc?: string[]; // 참조 (Carbon Copy)
bcc?: string[]; // 숨은참조 (Blind Carbon Copy) bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
@ -52,15 +53,29 @@ class MailSendSimpleService {
throw new Error('비활성 상태의 계정입니다.'); throw new Error('비활성 상태의 계정입니다.');
} }
// 3. HTML 생성 (템플릿 또는 커스텀) // 3. HTML 생성 (템플릿 + 추가 메시지 병합)
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) {
@ -261,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) {
@ -276,20 +303,23 @@ class MailSendSimpleService {
if (variables) { if (variables) {
content = this.replaceVariables(content, variables); content = this.replaceVariables(content, variables);
} }
html += `<p style="margin: 16px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '14px'};">${content}</p>`; // 텍스트는 왼쪽 정렬, 적절한 줄간격
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 += `<div style="text-align: center; margin: 24px 0;"> // 버튼은 왼쪽 정렬 (text-align 제거)
<a href="${component.url || '#'}" style="display: inline-block; padding: 12px 24px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 4px;">${buttonText}</a> html += `<div style="margin: 30px 0; text-align: left;">
<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>
</div>`; </div>`;
break; break;
case 'image': case 'image':
html += `<div style="text-align: center; margin: 16px 0;"> // 이미지는 왼쪽 정렬
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto;" /> 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>`; </div>`;
break; break;
case 'spacer': case 'spacer':
@ -298,7 +328,13 @@ class MailSendSimpleService {
} }
}); });
html += '</div>'; html += `
</td>
</tr>
</table>
</body>
</html>
`;
return html; return html;
} }
@ -320,6 +356,52 @@ class MailSendSimpleService {
return result; return result;
} }
/**
* 릿
* 릿 HTML의 body
*/
private mergeTemplateAndCustomContent(templateHtml: string, customContent: string): string {
// customContent에 HTML 태그가 없으면 기본 스타일 적용
let formattedCustomContent = customContent;
if (!customContent.includes('<')) {
// 일반 텍스트인 경우 단락으로 변환
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> 태그 앞에 삽입
if (templateHtml.includes('</body>')) {
return templateHtml.replace('</body>', `${formattedCustomContent}</body>`);
} else if (templateHtml.includes('</div>')) {
// 마지막 </div> 앞에 삽입
const lastDivIndex = templateHtml.lastIndexOf('</div>');
return (
templateHtml.substring(0, lastDivIndex) +
formattedCustomContent +
templateHtml.substring(lastDivIndex)
);
} else {
// 태그가 없으면 단순 결합
return templateHtml + formattedCustomContent;
}
}
/** /**
* SMTP * SMTP
*/ */

View File

@ -141,26 +141,35 @@ class MailTemplateFileService {
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;
} }
/** /**

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,8 +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, LayoutDashboard } from "lucide-react"; import { Mail, Plus, Loader2, RefreshCw, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
getMailAccounts, getMailAccounts,
@ -126,51 +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={() => router.push('/admin/mail/dashboard')}
> >
<LayoutDashboard className="w-4 h-4 mr-2" />
</Link>
</Button> <ChevronRight className="w-4 h-4 text-muted-foreground" />
<Button <span className="text-foreground font-medium"> </span>
variant="outline" </nav>
size="sm"
onClick={loadAccounts} <Separator />
disabled={loading}
> {/* 제목 + 액션 버튼들 */}
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <div className="flex items-center justify-between">
<div>
</Button> <h1 className="text-3xl font-bold text-foreground"> </h1>
<Button <p className="mt-2 text-muted-foreground">SMTP </p>
className="bg-orange-500 hover:bg-orange-600" </div>
onClick={handleOpenCreateModal} <div className="flex gap-2">
> <Button
<Plus className="w-4 h-4 mr-2" /> variant="outline"
size="sm"
</Button> 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}
@ -184,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, getMailStatistics } from "@/lib/api/mail"; import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
interface DashboardStats { interface DashboardStats {
totalAccounts: number; totalAccounts: number;
@ -39,20 +40,24 @@ export default function MailDashboardPage() {
const loadStats = async () => { const loadStats = async () => {
setLoading(true); setLoading(true);
try { try {
// 계정 수
const accounts = await getMailAccounts(); const accounts = await getMailAccounts();
// 템플릿 수
const templates = await getMailTemplates(); const templates = await getMailTemplates();
// 발송 통계
const mailStats = await getMailStatistics(); 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: mailStats.todayCount, sentToday: mailStats.todayCount,
receivedToday: 0, // 수신함 기능은 별도 receivedToday: receivedTodayCount,
sentThisMonth: mailStats.thisMonthCount, sentThisMonth: mailStats.thisMonthCount,
successRate: mailStats.successRate, successRate: mailStats.successRate,
}); });
@ -74,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: "템플릿 수",
@ -82,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: "오늘 발송",
@ -90,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: "오늘 수신",
@ -98,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>
@ -193,104 +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/sent"
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-indigo-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,9 +15,11 @@ import {
Filter, Filter,
SortAsc, SortAsc,
SortDesc, SortDesc,
LayoutDashboard, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
ReceivedMail, ReceivedMail,
@ -200,25 +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">
<Button
variant="outline"
size="sm"
onClick={() => router.push('/admin/mail/dashboard')}
> >
<LayoutDashboard className="w-4 h-4 mr-2" />
</Link>
</Button> <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"
@ -243,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) => (
@ -289,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">
{/* 검색 */} {/* 검색 */}
@ -300,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>
@ -322,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>
@ -341,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">
@ -356,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"
@ -390,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" />
@ -403,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" : ""
}`} }`}
> >
@ -421,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}
@ -431,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>
@ -456,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" />
@ -464,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>
@ -491,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>
@ -512,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>
@ -533,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>

View File

@ -28,9 +28,12 @@ import {
Upload, Upload,
Paperclip, Paperclip,
File, File,
LayoutDashboard, Settings,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
MailTemplate, MailTemplate,
@ -84,8 +87,18 @@ export default function MailSendPage() {
]); ]);
setAccounts(accountsData.filter((acc) => acc.status === "active")); setAccounts(accountsData.filter((acc) => acc.status === "active"));
setTemplates(templatesData); setTemplates(templatesData);
console.log('📦 데이터 로드 완료:', {
accounts: accountsData.length,
templates: templatesData.length,
templatesDetail: templatesData.map(t => ({
id: t.id,
name: t.name,
componentsCount: t.components?.length || 0
}))
});
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
console.error('❌ 데이터 로드 실패:', err);
toast({ toast({
title: "데이터 로드 실패", title: "데이터 로드 실패",
description: err.message, description: err.message,
@ -96,30 +109,67 @@ export default function MailSendPage() {
} }
}; };
// 템플릿 선택 시 // 템플릿 선택 시 (원본 다시 로드)
const handleTemplateChange = (templateId: string) => { const handleTemplateChange = async (templateId: string) => {
console.log('🔄 템플릿 선택됨:', templateId);
// "__custom__"는 직접 작성을 의미 // "__custom__"는 직접 작성을 의미
if (templateId === "__custom__") { if (templateId === "__custom__") {
console.log('✏️ 직접 작성 모드');
setSelectedTemplateId(""); setSelectedTemplateId("");
setTemplateVariables([]); setTemplateVariables([]);
setVariables({}); setVariables({});
return; return;
} }
setSelectedTemplateId(templateId); try {
const template = templates.find((t) => t.id === templateId); // 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
if (template) { console.log('🔃 원본 템플릿 API에서 재로드 중...');
setSubject(template.subject); const freshTemplates = await getMailTemplates();
const vars = extractTemplateVariables(template); const template = freshTemplates.find((t) => t.id === templateId);
setTemplateVariables(vars);
const initialVars: Record<string, string> = {}; console.log('📋 찾은 템플릿:', {
vars.forEach((v) => { found: !!template,
initialVars[v] = ""; templateId,
availableTemplates: freshTemplates.length,
template: template ? {
id: template.id,
name: template.name,
componentsCount: template.components?.length || 0
} : null
});
if (template) {
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
setTemplates(freshTemplates);
setSelectedTemplateId(templateId);
setSubject(template.subject);
const vars = extractTemplateVariables(template);
setTemplateVariables(vars);
const initialVars: Record<string, string> = {};
vars.forEach((v) => {
initialVars[v] = "";
});
setVariables(initialVars);
console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
subject: template.subject,
variables: vars
});
} else {
setSelectedTemplateId("");
setTemplateVariables([]);
setVariables({});
console.warn('⚠️ 템플릿을 찾을 수 없음');
}
} catch (error) {
console.error('❌ 템플릿 재로드 실패:', error);
toast({
title: "템플릿 로드 실패",
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
variant: "destructive",
}); });
setVariables(initialVars);
} else {
setTemplateVariables([]);
setVariables({});
} }
}; };
@ -233,6 +283,13 @@ export default function MailSendPage() {
formData.append("accountId", selectedAccountId); formData.append("accountId", selectedAccountId);
if (selectedTemplateId) { if (selectedTemplateId) {
formData.append("templateId", selectedTemplateId); formData.append("templateId", selectedTemplateId);
// 🎯 수정된 템플릿 컴포넌트 전송
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
if (currentTemplate) {
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
}
} }
formData.append("to", JSON.stringify(to)); formData.append("to", JSON.stringify(to));
if (cc.length > 0) { if (cc.length > 0) {
@ -407,6 +464,12 @@ export default function MailSendPage() {
// 미리보기 // 미리보기
const handlePreview = () => { const handlePreview = () => {
console.log('👁️ 미리보기 토글:', {
current: showPreview,
willBe: !showPreview,
selectedTemplateId,
hasCustomHtml: !!customHtml
});
setShowPreview(!showPreview); setShowPreview(!showPreview);
}; };
@ -414,7 +477,27 @@ export default function MailSendPage() {
if (selectedTemplateId) { if (selectedTemplateId) {
const template = templates.find((t) => t.id === selectedTemplateId); const template = templates.find((t) => t.id === selectedTemplateId);
if (template) { if (template) {
return renderTemplateToHtml(template, variables); console.log('🎨 템플릿 미리보기:', {
templateId: selectedTemplateId,
templateName: template.name,
componentsCount: template.components?.length || 0,
components: template.components,
variables
});
const html = renderTemplateToHtml(template, variables);
console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
// 추가 메시지가 있으면 병합
if (customHtml && customHtml.trim()) {
const additionalContent = `
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
${convertTextToHtml(customHtml)}
</div>
`;
return html + additionalContent;
}
return html;
} }
} }
// 일반 텍스트를 HTML로 변환하여 미리보기 // 일반 텍스트를 HTML로 변환하여 미리보기
@ -430,26 +513,34 @@ export default function MailSendPage() {
} }
return ( return (
<div className="p-6 space-y-6 bg-slate-50 min-h-screen"> <div className="p-6 space-y-8 bg-background min-h-screen">
{/* 헤더 */} {/* 헤더 */}
<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
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>
<h1 className="text-3xl font-bold text-foreground"> </h1>
<p className="mt-2 text-muted-foreground">릿 </p>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => router.push('/admin/mail/dashboard')}
>
<LayoutDashboard className="w-4 h-4 mr-2" />
</Button>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className={`grid gap-8 ${showPreview ? 'lg:grid-cols-3' : 'grid-cols-1'}`}>
{/* 메일 작성 폼 */} {/* 메일 작성 폼 */}
<div className="lg:col-span-2 space-y-6"> <div className={showPreview ? 'lg:col-span-2' : 'col-span-1'}>
<div className="space-y-8">
{/* 발송 설정 */} {/* 발송 설정 */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -458,7 +549,7 @@ export default function MailSendPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* 발송 계정 선택 */} {/* 발송 계정 선택 */}
<div> <div>
<Label htmlFor="account"> *</Label> <Label htmlFor="account"> *</Label>
@ -504,7 +595,7 @@ export default function MailSendPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* 받는 사람 */} {/* 받는 사람 */}
<div> <div>
<Label htmlFor="to" className="flex items-center gap-2"> <Label htmlFor="to" className="flex items-center gap-2">
@ -512,7 +603,7 @@ export default function MailSendPage() {
* *
</Label> </Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-white min-h-[42px]"> <div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{to.map((email) => ( {to.map((email) => (
<div <div
key={email} key={email}
@ -537,7 +628,7 @@ export default function MailSendPage() {
className="flex-1 outline-none min-w-[200px] text-sm" className="flex-1 outline-none min-w-[200px] text-sm"
/> />
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
💡 , (,), 💡 , (,),
</p> </p>
</div> </div>
@ -550,7 +641,7 @@ export default function MailSendPage() {
(CC) (CC)
</Label> </Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-white min-h-[42px]"> <div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{cc.map((email) => ( {cc.map((email) => (
<div <div
key={email} key={email}
@ -575,7 +666,7 @@ export default function MailSendPage() {
className="flex-1 outline-none min-w-[200px] text-sm" className="flex-1 outline-none min-w-[200px] text-sm"
/> />
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
</p> </p>
</div> </div>
@ -588,7 +679,7 @@ export default function MailSendPage() {
(BCC) (BCC)
</Label> </Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-white min-h-[42px]"> <div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-card min-h-[42px]">
{bcc.map((email) => ( {bcc.map((email) => (
<div <div
key={email} key={email}
@ -613,7 +704,7 @@ export default function MailSendPage() {
className="flex-1 outline-none min-w-[200px] text-sm" className="flex-1 outline-none min-w-[200px] text-sm"
/> />
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
🔒 () 🔒 ()
</p> </p>
</div> </div>
@ -629,7 +720,7 @@ export default function MailSendPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* 제목 */} {/* 제목 */}
<div> <div>
<Label htmlFor="subject"> *</Label> <Label htmlFor="subject"> *</Label>
@ -663,22 +754,233 @@ export default function MailSendPage() {
</div> </div>
)} )}
{/* 직접 작성 */} {/* 템플릿 편집 가능한 미리보기 */}
{!selectedTemplateId && ( {selectedTemplateId && (() => {
<div> const template = templates.find((t) => t.id === selectedTemplateId);
<Label htmlFor="customHtml"></Label> if (template) {
<Textarea return (
id="customHtml" <div className="border rounded-lg overflow-hidden">
value={customHtml} <div className="bg-gradient-to-r from-orange-50 to-amber-50 border-b px-4 py-3 flex items-center justify-between">
onChange={(e) => setCustomHtml(e.target.value)} <Label className="font-semibold text-foreground flex items-center gap-2">
placeholder="메일 내용을 입력하세요&#10;&#10;줄바꿈은 자동으로 처리됩니다." <FileText className="w-4 h-4" />
rows={10} ( )
/> </Label>
<p className="text-xs text-gray-500 mt-1"> <Button
💡 variant="outline"
</p> size="sm"
</div> onClick={() => router.push(`/admin/mail/templates`)}
)} className="flex items-center gap-1"
>
<Settings className="w-3 h-3" />
</Button>
</div>
<div className="bg-card p-6 space-y-4">
{template.components.map((component, index) => {
// 변수 치환 및 HTML 태그 제거
let content = component.content || '';
let text = component.text || '';
// HTML 태그 제거 (일반 텍스트만 표시)
const stripHtml = (html: string) => {
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
};
content = stripHtml(content);
if (variables) {
Object.entries(variables).forEach(([key, value]) => {
content = content.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
});
}
switch (component.type) {
case 'text':
return (
<div key={component.id} className="group relative">
<Textarea
value={content}
onChange={(e) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, content: `<p>${e.target.value}</p>` } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}}
onFocus={(e) => {
// 🎯 클릭 시 placeholder 같은 텍스트 자동 제거
const currentValue = e.target.value.trim();
if (currentValue === '텍스트를 입력하세요' ||
currentValue === '텍스트를 입력하세요...' ||
currentValue === '여기를 클릭하여 내용을 입력하세요' ||
currentValue === '여기를 클릭하여 내용을 입력하세요...') {
e.target.value = '';
// state도 업데이트
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, content: '' } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}
}}
className="min-h-[100px] resize-none bg-card border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="여기를 클릭하여 내용을 입력하세요..."
/>
</div>
);
case 'button':
return (
<div key={component.id} className="group relative">
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-sm text-muted-foreground mb-1 block"> </Label>
<Input
value={text}
onChange={(e) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, text: e.target.value } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
}}
className="bg-card border border focus:ring-2 focus:ring-orange-500"
placeholder="버튼에 표시될 텍스트"
/>
</div>
<button
style={{
...component.styles,
padding: '12px 24px',
borderRadius: '6px',
fontWeight: '600',
marginTop: '20px'
}}
className="whitespace-nowrap"
>
{text || '버튼'}
</button>
</div>
</div>
);
case 'image':
return (
<div key={component.id} className="group relative">
<div className="space-y-3">
<div className="relative">
<img
src={component.src}
alt="메일 이미지"
className="w-full rounded-lg border border"
/>
<div className="absolute top-2 right-2 flex gap-2">
<Button
size="sm"
variant="secondary"
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) => {
const updatedTemplate = {
...template,
components: template.components.map((c) =>
c.id === component.id ? { ...c, src: event.target?.result as string } : c
),
};
setTemplates(templates.map((t) =>
t.id === template.id ? updatedTemplate : t
));
};
reader.readAsDataURL(file);
}
};
input.click();
}}
className="bg-card/90 backdrop-blur-sm shadow-lg"
>
<Upload className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
</div>
</div>
);
case 'spacer':
return (
<div
key={component.id}
style={{ height: `${component.height || 20}px` }}
className="bg-background rounded flex items-center justify-center text-xs text-gray-400"
>
</div>
);
default:
return null;
}
})}
</div>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-4 py-3 border-t border-green-200">
<p className="text-sm text-green-800 flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 text-green-600" />
</p>
</div>
</div>
);
}
return null;
})()}
{/* 메일 내용 입력 - 항상 표시 */}
<div>
<Label htmlFor="customHtml">
{selectedTemplateId ? "추가 메시지 (선택)" : "내용 *"}
</Label>
<Textarea
id="customHtml"
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
placeholder={
selectedTemplateId
? "템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
: "메일 내용을 입력하세요\n\n줄바꿈은 자동으로 처리됩니다."
}
rows={10}
/>
<p className="text-xs text-muted-foreground mt-1">
{selectedTemplateId ? (
<>💡 릿 </>
) : (
<>💡 </>
)}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -690,7 +992,7 @@ export default function MailSendPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* 드래그 앤 드롭 영역 */} {/* 드래그 앤 드롭 영역 */}
<div <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
@ -699,7 +1001,7 @@ export default function MailSendPage() {
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${ className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging isDragging
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-gray-300 hover:border-primary/50" : "border hover:border-primary/50"
}`} }`}
onClick={() => document.getElementById("file-input")?.click()} onClick={() => document.getElementById("file-input")?.click()}
> >
@ -711,10 +1013,10 @@ export default function MailSendPage() {
className="hidden" className="hidden"
/> />
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" /> <Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" />
<p className="text-sm text-gray-600 mb-1"> <p className="text-sm text-muted-foreground mb-1">
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
5, 10MB 5, 10MB
</p> </p>
</div> </div>
@ -727,15 +1029,15 @@ export default function MailSendPage() {
{attachments.map((file, index) => ( {attachments.map((file, index) => (
<div <div
key={index} key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border" className="flex items-center justify-between p-3 bg-background rounded-lg border"
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<File className="w-5 h-5 text-gray-500 flex-shrink-0" /> <File className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate"> <p className="text-sm font-medium text-foreground truncate">
{file.name} {file.name}
</p> </p>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {formatFileSize(file.size)}
</p> </p>
</div> </div>
@ -785,6 +1087,7 @@ export default function MailSendPage() {
</Button> </Button>
</div> </div>
</div>
</div> </div>
{/* 미리보기 패널 */} {/* 미리보기 패널 */}
@ -807,7 +1110,7 @@ export default function MailSendPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="border rounded-lg p-4 bg-white overflow-auto max-h-[70vh]"> <div className="border rounded-lg p-4 bg-card overflow-auto max-h-[70vh]">
<div className="space-y-2 mb-4 pb-4 border-b"> <div className="space-y-2 mb-4 pb-4 border-b">
<div className="text-sm"> <div className="text-sm">
<span className="font-semibold"> :</span> {to.join(", ") || "-"} <span className="font-semibold"> :</span> {to.join(", ") || "-"}
@ -830,7 +1133,7 @@ export default function MailSendPage() {
<span className="font-semibold">:</span> {attachments.length} <span className="font-semibold">:</span> {attachments.length}
<div className="ml-4 mt-1 space-y-1"> <div className="ml-4 mt-1 space-y-1">
{attachments.map((file, index) => ( {attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 text-xs text-gray-600"> <div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
<File className="w-3 h-3" /> <File className="w-3 h-3" />
<span className="truncate">{file.name}</span> <span className="truncate">{file.name}</span>
<span className="text-gray-400">({formatFileSize(file.size)})</span> <span className="text-gray-400">({formatFileSize(file.size)})</span>

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -28,9 +29,15 @@ import {
Loader2, Loader2,
X, X,
File, File,
LayoutDashboard, ChevronRight,
ChevronDown,
ChevronUp,
Send,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -43,6 +50,7 @@ import {
deleteSentMail, deleteSentMail,
getMailAccounts, getMailAccounts,
MailAccount, MailAccount,
getMailStatistics,
} from "@/lib/api/mail"; } from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@ -53,6 +61,15 @@ export default function SentMailPage() {
const [accounts, setAccounts] = useState<MailAccount[]>([]); const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedMail, setSelectedMail] = useState<SentMailHistory | null>(null); 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 [searchTerm, setSearchTerm] = useState("");
@ -66,6 +83,10 @@ export default function SentMailPage() {
useEffect(() => { useEffect(() => {
loadAccounts(); loadAccounts();
loadStats();
}, []);
useEffect(() => {
loadMails(); loadMails();
}, [page, filterStatus, filterAccountId, sortBy, sortOrder]); }, [page, filterStatus, filterAccountId, sortBy, sortOrder]);
@ -83,6 +104,20 @@ export default function SentMailPage() {
} }
}; };
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 () => { const loadMails = async () => {
try { try {
setLoading(true); setLoading(true);
@ -128,6 +163,7 @@ export default function SentMailPage() {
description: "발송 이력이 삭제되었습니다.", description: "발송 이력이 삭제되었습니다.",
}); });
loadMails(); loadMails();
loadStats();
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
toast({ toast({
@ -157,7 +193,7 @@ export default function SentMailPage() {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
}; };
if (loading && page === 1) { if (loading && page === 1 && mails.length === 0) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="w-8 h-8 animate-spin text-primary" />
@ -166,204 +202,313 @@ export default function SentMailPage() {
} }
return ( return (
<div className="p-6 space-y-6 bg-slate-50 min-h-screen"> <div className="p-6 space-y-6 bg-background min-h-screen">
{/* 헤더 */} {/* 헤더 */}
<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 flex items-center gap-2"> <nav className="flex items-center gap-2 text-sm">
<Inbox className="w-8 h-8" /> <Link
href="/admin/mail/dashboard"
</h1> className="text-muted-foreground hover:text-foreground transition-colors"
<p className="mt-2 text-gray-600"> </p> >
</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>
<Button
variant="outline"
size="sm"
onClick={() => router.push('/admin/mail/dashboard')}
>
<LayoutDashboard className="w-4 h-4 mr-2" />
</Button>
</div> </div>
{/* 필터 및 검색 */} {/* 통계 카드 */}
<Card> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<CardHeader> <Card>
<CardTitle className="flex items-center gap-2"> <CardContent className="pt-6">
<Filter className="w-5 h-5" /> <div className="flex items-center justify-between">
<div>
</CardTitle> <p className="text-sm font-medium text-muted-foreground"> </p>
</CardHeader> <p className="text-2xl font-bold text-foreground mt-1">{stats.totalSent}</p>
<CardContent className="space-y-4"> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="p-3 bg-muted rounded-lg">
{/* 검색 */} <Send className="w-6 h-6 text-muted-foreground" />
<div className="md:col-span-2">
<Label htmlFor="search"></Label>
<div className="flex gap-2">
<Input
id="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목 또는 받는사람 검색..."
/>
<Button onClick={handleSearch} size="icon">
<Search className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
</CardContent>
</Card>
{/* 상태 필터 */} <Card>
<div> <CardContent className="pt-6">
<Label></Label> <div className="flex items-center justify-between">
<Select value={filterStatus} onValueChange={(v: any) => { <div>
setFilterStatus(v); <p className="text-sm font-medium text-muted-foreground"> </p>
setPage(1); <p className="text-2xl font-bold text-foreground mt-1">{stats.successCount}</p>
}}> </div>
<SelectTrigger> <div className="p-3 bg-muted rounded-lg">
<SelectValue /> <CheckCircle2 className="w-6 h-6 text-muted-foreground" />
</SelectTrigger> </div>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="success"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</CardContent>
</Card>
{/* 계정 필터 */} <Card>
<div> <CardContent className="pt-6">
<Label> </Label> <div className="flex items-center justify-between">
<Select value={filterAccountId} onValueChange={(v) => { <div>
setFilterAccountId(v); <p className="text-sm font-medium text-muted-foreground"> </p>
setPage(1); <p className="text-2xl font-bold text-foreground mt-1">{stats.failedCount}</p>
}}> </div>
<SelectTrigger> <div className="p-3 bg-muted rounded-lg">
<SelectValue /> <XCircle className="w-6 h-6 text-muted-foreground" />
</SelectTrigger> </div>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </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 justify-between">
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button <Search className="w-5 h-5" />
variant="outline" <CardTitle></CardTitle>
size="sm"
onClick={() => {
setSortBy('sentAt');
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
}}
>
<Calendar className="w-4 h-4 mr-2" />
{sortBy === 'sentAt' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSortBy('subject');
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
}}
>
{sortBy === 'subject' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
</div> </div>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
onClick={loadMails} onClick={() => setShowFilters(!showFilters)}
disabled={loading}
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <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> </Button>
</div> </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> </CardContent>
</Card> </Card>
{/* 메일 목록 */} {/* 메일 목록 */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
({total}) ({total})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{mails.length === 0 ? ( {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"> <div className="text-center py-12">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" /> <Inbox className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<p className="text-gray-500"> </p> <p className="text-muted-foreground"> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{mails.map((mail) => ( {mails.map((mail) => (
<div <div
key={mail.id} key={mail.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors" className="p-4 border rounded-lg hover:bg-muted/50 transition-colors"
> >
<div className="flex-1 min-w-0"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2 mb-1"> {/* 메일 정보 */}
{mail.status === 'success' ? ( <div className="flex-1 min-w-0">
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" /> <div className="flex items-center gap-2 mb-2">
) : ( {/* 상태 배지 */}
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" /> {mail.status === 'success' ? (
)} <Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
<h3 className="font-medium text-gray-900 truncate"> <CheckCircle2 className="w-3 h-3 mr-1" />
{mail.subject}
</h3> </Badge>
{mail.attachments && mail.attachments.length > 0 && ( ) : (
<Paperclip className="w-4 h-4 text-gray-400" /> <Badge variant="destructive">
)} <XCircle className="w-3 h-3 mr-1" />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500"> </Badge>
<div className="flex items-center gap-1"> )}
<User className="w-3 h-3" />
<span>{mail.accountName}</span> {/* 첨부파일 */}
</div> {mail.attachmentCount > 0 && (
<div className="flex items-center gap-1"> <Badge variant="outline">
<Mail className="w-3 h-3" /> <Paperclip className="w-3 h-3 mr-1" />
<span>: {mail.to.length}</span> {mail.attachmentCount}
{mail.cc && mail.cc.length > 0 && ( </Badge>
<span className="text-gray-400">( {mail.cc.length})</span>
)} )}
</div> </div>
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" /> {/* 제목 */}
<span>{formatDate(mail.sentAt)}</span> <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> </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>
{mail.status === 'failed' && mail.errorMessage && (
<div className="mt-1 text-sm text-red-600">
: {mail.errorMessage}
</div>
)}
</div>
<div className="flex gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedMail(mail)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(mail.id)}
className="text-red-500 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
))} ))}
@ -372,245 +517,183 @@ export default function SentMailPage() {
{/* 페이징 */} {/* 페이징 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex justify-center gap-2 mt-6"> <div className="flex items-center justify-between mt-6 pt-6 border-t">
<Button <p className="text-sm text-muted-foreground">
variant="outline" {page} / {totalPages}
size="sm" </p>
onClick={() => setPage((p) => Math.max(1, p - 1))} <div className="flex gap-2">
disabled={page === 1} <Button
> variant="outline"
size="sm"
</Button> onClick={() => setPage(Math.max(1, page - 1))}
<div className="flex items-center px-4 text-sm text-gray-600"> disabled={page === 1}
{page} / {totalPages} >
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
</Button>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
</Button>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* 상세보기 모달 */} {/* 메일 상세 모달 */}
<Dialog open={selectedMail !== null} onOpenChange={(open) => !open && setSelectedMail(null)}> {selectedMail && (
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> <Dialog open={!!selectedMail} onOpenChange={() => setSelectedMail(null)}>
<DialogHeader> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogTitle className="flex items-center justify-between"> <DialogHeader>
<div className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{selectedMail?.status === 'success' ? ( <Mail className="w-5 h-5" />
<CheckCircle2 className="w-5 h-5 text-green-500" />
</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>
) : ( ) : (
<XCircle className="w-5 h-5 text-red-500" /> <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>
)} )}
<span> </span>
</div> </div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedMail(null)}
>
<X className="w-4 h-4" />
</Button>
</DialogTitle>
</DialogHeader>
{selectedMail && ( <Separator />
<div className="space-y-4">
{/* 발송 정보 */} {/* 발신 정보 */}
<Card> <div className="space-y-4">
<CardHeader> <h3 className="font-semibold text-foreground"> </h3>
<CardTitle className="text-lg"> </CardTitle> <div className="space-y-2 text-sm">
</CardHeader> <div className="flex">
<CardContent className="space-y-3"> <span className="w-24 text-muted-foreground">:</span>
<div className="grid grid-cols-2 gap-4"> <span className="flex-1 font-medium">{selectedMail.from}</span>
<div>
<Label className="text-sm font-medium text-gray-700"> </Label>
<p className="text-sm text-gray-900 mt-1">
{selectedMail.accountName} ({selectedMail.accountEmail})
</p>
</div>
<div>
<Label className="text-sm font-medium text-gray-700"> </Label>
<p className="text-sm text-gray-900 mt-1">
{formatDate(selectedMail.sentAt)}
</p>
</div>
</div> </div>
<div className="flex">
<div> <span className="w-24 text-muted-foreground">:</span>
<Label className="text-sm font-medium text-gray-700"></Label> <span className="flex-1">
<div className="mt-1"> {Array.isArray(selectedMail.to) ? selectedMail.to.join(", ") : selectedMail.to}
{selectedMail.status === 'success' ? ( </span>
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
</span>
)}
</div>
</div> </div>
{selectedMail.messageId && (
<div>
<Label className="text-sm font-medium text-gray-700"> ID</Label>
<p className="text-sm text-gray-600 mt-1 font-mono">
{selectedMail.messageId}
</p>
</div>
)}
{selectedMail.errorMessage && (
<div>
<Label className="text-sm font-medium text-red-700"> </Label>
<p className="text-sm text-red-600 mt-1">
{selectedMail.errorMessage}
</p>
</div>
)}
</CardContent>
</Card>
{/* 수신자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="flex flex-wrap gap-2 mt-1">
{selectedMail.to.map((email, i) => (
<span key={i} className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-700">
{email}
</span>
))}
</div>
</div>
{selectedMail.cc && selectedMail.cc.length > 0 && ( {selectedMail.cc && selectedMail.cc.length > 0 && (
<div> <div className="flex">
<Label className="text-sm font-medium text-gray-700"> (CC)</Label> <span className="w-24 text-muted-foreground">:</span>
<div className="flex flex-wrap gap-2 mt-1"> <span className="flex-1">{selectedMail.cc.join(", ")}</span>
{selectedMail.cc.map((email, i) => (
<span key={i} className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
{email}
</span>
))}
</div>
</div> </div>
)} )}
{selectedMail.bcc && selectedMail.bcc.length > 0 && ( {selectedMail.bcc && selectedMail.bcc.length > 0 && (
<div> <div className="flex">
<Label className="text-sm font-medium text-gray-700"> (BCC)</Label> <span className="w-24 text-muted-foreground">:</span>
<div className="flex flex-wrap gap-2 mt-1"> <span className="flex-1">{selectedMail.bcc.join(", ")}</span>
{selectedMail.bcc.map((email, i) => (
<span key={i} className="px-2 py-1 text-xs rounded bg-purple-100 text-purple-700">
{email}
</span>
))}
</div>
</div> </div>
)} )}
<div className="flex">
<span className="w-24 text-muted-foreground">:</span>
<span className="flex-1">{formatDate(selectedMail.sentAt)}</span>
</div>
</div>
</div>
{selectedMail.accepted && selectedMail.accepted.length > 0 && ( <Separator />
<div>
<Label className="text-sm font-medium text-green-700"></Label>
<p className="text-sm text-gray-600 mt-1">
{selectedMail.accepted.join(", ")}
</p>
</div>
)}
{selectedMail.rejected && selectedMail.rejected.length > 0 && (
<div>
<Label className="text-sm font-medium text-red-700"></Label>
<p className="text-sm text-gray-600 mt-1">
{selectedMail.rejected.join(", ")}
</p>
</div>
)}
</CardContent>
</Card>
{/* 메일 내용 */} {/* 메일 내용 */}
<Card> <div className="space-y-4">
<CardHeader> <h3 className="font-semibold text-foreground"> </h3>
<CardTitle className="text-lg"> </CardTitle> <div>
</CardHeader> <p className="text-sm text-muted-foreground mb-2"></p>
<CardContent className="space-y-3"> <p className="font-medium">{selectedMail.subject || "(제목 없음)"}</p>
</div>
{selectedMail.templateUsed && (
<div> <div>
<Label className="text-sm font-medium text-gray-700"></Label> <p className="text-sm text-muted-foreground mb-2"> 릿</p>
<p className="text-sm text-gray-900 mt-1 font-medium"> <Badge variant="outline">{selectedMail.templateUsed}</Badge>
{selectedMail.subject}
</p>
</div> </div>
)}
{selectedMail.templateName && ( <div>
<div> <p className="text-sm text-muted-foreground mb-2"></p>
<Label className="text-sm font-medium text-gray-700"> 릿</Label> <div
<p className="text-sm text-gray-600 mt-1"> className="p-4 border rounded-lg bg-muted/30 max-h-96 overflow-y-auto"
{selectedMail.templateName} dangerouslySetInnerHTML={{ __html: selectedMail.htmlBody || selectedMail.textBody || "" }}
</p> />
</div> </div>
)} </div>
<div>
<Label className="text-sm font-medium text-gray-700"> </Label>
<div
className="mt-2 border rounded-lg p-4 bg-white max-h-96 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: selectedMail.htmlContent }}
/>
</div>
</CardContent>
</Card>
{/* 첨부파일 */} {/* 첨부파일 */}
{selectedMail.attachments && selectedMail.attachments.length > 0 && ( {selectedMail.attachments && selectedMail.attachments.length > 0 && (
<Card> <>
<CardHeader> <Separator />
<CardTitle className="text-lg flex items-center gap-2"> <div className="space-y-4">
<Paperclip className="w-5 h-5" /> <h3 className="font-semibold text-foreground flex items-center gap-2">
({selectedMail.attachments.length}) <Paperclip className="w-4 h-4" />
</CardTitle> ({selectedMail.attachments.length})
</CardHeader> </h3>
<CardContent>
<div className="space-y-2"> <div className="space-y-2">
{selectedMail.attachments.map((file, i) => ( {selectedMail.attachments.map((att, idx) => (
<div <div key={idx} className="flex items-center justify-between p-3 border rounded-lg">
key={i} <div className="flex items-center gap-3">
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border" <File className="w-5 h-5 text-muted-foreground" />
> <div>
<div className="flex items-center gap-3 flex-1 min-w-0"> <p className="text-sm font-medium">{att.filename}</p>
<File className="w-5 h-5 text-gray-500 flex-shrink-0" /> <p className="text-xs text-muted-foreground">{formatFileSize(att.size || 0)}</p>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.originalName}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)} {file.mimetype}
</p>
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</CardContent> </div>
</Card> </>
)}
{/* 수신 결과 (성공/실패 목록) */}
{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> </div>
)} </DialogContent>
</DialogContent> </Dialog>
</Dialog> )}
</div> </div>
); );
} }

View File

@ -3,8 +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, LayoutDashboard } from "lucide-react"; import { Plus, FileText, Loader2, RefreshCw, Search, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { import {
MailTemplate, MailTemplate,
getMailTemplates, getMailTemplates,
@ -130,60 +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={() => router.push('/admin/mail/dashboard')}
> >
<LayoutDashboard className="w-4 h-4 mr-2" />
</Link>
</Button> <ChevronRight className="w-4 h-4 text-muted-foreground" />
<Button <span className="text-foreground font-medium">릿 </span>
variant="outline" </nav>
size="sm"
onClick={loadTemplates} <Separator />
disabled={loading}
> {/* 제목 + 액션 버튼들 */}
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <div className="flex items-center justify-between">
<div>
</Button> <h1 className="text-3xl font-bold text-foreground"> 릿 </h1>
<Button <p className="mt-2 text-muted-foreground"> 릿 </p>
onClick={handleOpenCreateModal} </div>
className="bg-orange-500 hover:bg-orange-600" <div className="flex gap-2">
> <Button
<Plus className="w-4 h-4 mr-2" /> variant="outline"
릿 size="sm"
</Button> 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) => (
@ -198,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" />
릿 릿
@ -239,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

@ -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

@ -6,12 +6,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"; // 프록시 사용
} }
// 서버 환경에서 localhost:5555 → 39.117.244.52:8080 // 서버 환경에서 localhost:5555 → 39.117.244.52:8080

View File

@ -471,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;
}
/** /**
* *
*/ */