383 lines
13 KiB
TypeScript
383 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, downloadMailAttachment } from "@/lib/api/mail";
|
|
import DOMPurify from "isomorphic-dompurify";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
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 router = useRouter();
|
|
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);
|
|
|
|
// API 함수 사용
|
|
const blob = await downloadMailAttachment(accountId, seqno, index);
|
|
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);
|
|
|
|
// API 함수 사용
|
|
const blob = await downloadMailAttachment(accountId, seqno, index);
|
|
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"
|
|
onClick={() => {
|
|
if (!mail) return;
|
|
const replyData = {
|
|
type: 'reply',
|
|
originalFrom: mail.from,
|
|
originalSubject: mail.subject,
|
|
originalDate: mail.date,
|
|
originalBody: mail.body,
|
|
};
|
|
router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
|
|
onClose();
|
|
}}
|
|
>
|
|
<Reply className="w-4 h-4 mr-2" />
|
|
답장
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!mail) return;
|
|
const forwardData = {
|
|
type: 'forward',
|
|
originalFrom: mail.from,
|
|
originalSubject: mail.subject,
|
|
originalDate: mail.date,
|
|
originalBody: mail.body,
|
|
originalAttachments: mail.attachments,
|
|
};
|
|
router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
|
|
onClose();
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|