ERP-node/frontend/components/mail/MailDetailModal.tsx

386 lines
13 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
X,
Paperclip,
Reply,
Forward,
Loader2,
AlertCircle,
} from "lucide-react";
import { MailDetail, getMailDetail, markMailAsRead } from "@/lib/api/mail";
import DOMPurify from "isomorphic-dompurify";
interface MailDetailModalProps {
isOpen: boolean;
onClose: () => void;
accountId: string;
mailId: string; // "accountId-seqno" 형식
onMailRead?: () => void; // 읽음 처리 후 목록 갱신용
}
export default function MailDetailModal({
isOpen,
onClose,
accountId,
mailId,
onMailRead,
}: MailDetailModalProps) {
const [mail, setMail] = useState<MailDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imageBlobUrls, setImageBlobUrls] = useState<{ [key: number]: string }>({});
useEffect(() => {
if (isOpen && mailId) {
loadMailDetail();
}
// 컴포넌트 언마운트 시 Blob URL 정리
return () => {
Object.values(imageBlobUrls).forEach((url) => URL.revokeObjectURL(url));
};
}, [isOpen, mailId]);
const loadMailDetail = async () => {
setLoading(true);
setError(null);
try {
// mailId에서 seqno 추출 (예: "account123-45" -> 45)
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
if (isNaN(seqno)) {
throw new Error("유효하지 않은 메일 ID입니다.");
}
// 메일 상세 조회
const mailDetail = await getMailDetail(accountId, seqno);
setMail(mailDetail);
// 읽음 처리
if (!mailDetail.isRead) {
await markMailAsRead(accountId, seqno);
onMailRead?.(); // 목록 갱신
}
// 이미지 첨부파일 자동 로드
if (mailDetail.attachments) {
mailDetail.attachments.forEach((attachment, index) => {
if (attachment.contentType?.startsWith('image/')) {
loadImageAttachment(index);
}
});
}
} catch (err) {
console.error("메일 상세 조회 실패:", err);
setError(
err instanceof Error
? err.message
: "메일을 불러오는데 실패했습니다."
);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const sanitizeHtml = (html: string) => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
"p",
"br",
"strong",
"em",
"u",
"a",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"img",
"div",
"span",
"table",
"tr",
"td",
"th",
"thead",
"tbody",
],
ALLOWED_ATTR: ["href", "src", "alt", "title", "style", "class"],
});
};
const 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) => {
try {
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
const token = localStorage.getItem("authToken");
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
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');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Blob URL 정리
URL.revokeObjectURL(url);
} catch (err) {
console.error('첨부파일 다운로드 실패:', err);
alert('첨부파일 다운로드에 실패했습니다.');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl font-bold truncate">
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground"> ...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-16">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<p className="text-destructive">{error}</p>
<Button onClick={loadMailDetail} variant="outline" className="mt-4">
</Button>
</div>
) : mail ? (
<div className="flex-1 overflow-y-auto space-y-4">
{/* 메일 헤더 */}
<div className="border-b pb-4 space-y-2">
<h2 className="text-2xl font-bold text-foreground">
{mail.subject}
</h2>
<div className="flex items-center justify-between">
<div className="space-y-1 text-sm">
<div>
<span className="font-medium text-foreground">:</span>{" "}
<span className="text-foreground">{mail.from}</span>
</div>
<div>
<span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground">{mail.to}</span>
</div>
{mail.cc && (
<div>
<span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground">{mail.cc}</span>
</div>
)}
<div>
<span className="font-medium text-foreground">:</span>{" "}
<span className="text-muted-foreground">
{formatDate(mail.date)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Reply className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm">
<Forward className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
{/* 첨부파일 */}
{mail.attachments && mail.attachments.length > 0 && (
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">
({mail.attachments.length})
</span>
</div>
<div className="space-y-2">
{mail.attachments.map((attachment, index) => {
const isImage = attachment.contentType?.startsWith('image/');
return (
<div key={index}>
{/* 첨부파일 정보 */}
<div className="flex items-center justify-between bg-card rounded px-3 py-2 border hover:border-primary/30 transition-colors">
<div className="flex items-center gap-2">
<Paperclip className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-foreground">
{attachment.filename}
</span>
<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>
</div>
)}
{/* 메일 본문 */}
<div className="border rounded-lg p-6 bg-card min-h-[300px]">
{mail.htmlBody ? (
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(mail.htmlBody),
}}
/>
) : mail.textBody ? (
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
{mail.textBody}
</pre>
) : (
<p className="text-muted-foreground text-center py-8">
.
</p>
)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}