ERP-node/frontend/app/(main)/admin/mail/receive/page.tsx

556 lines
20 KiB
TypeScript
Raw Normal View History

2025-10-01 16:15:53 +09:00
"use client";
2025-10-01 17:01:31 +09:00
import React, { useState, useEffect } from "react";
2025-10-01 16:15:53 +09:00
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
2025-10-01 17:01:31 +09:00
import {
Inbox,
Mail,
RefreshCw,
Loader2,
CheckCircle,
Paperclip,
AlertCircle,
Search,
Filter,
SortAsc,
SortDesc,
} from "lucide-react";
import {
MailAccount,
ReceivedMail,
getMailAccounts,
getReceivedMails,
testImapConnection,
} from "@/lib/api/mail";
import MailDetailModal from "@/components/mail/MailDetailModal";
2025-10-01 16:15:53 +09:00
export default function MailReceivePage() {
2025-10-01 17:01:31 +09:00
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [mails, setMails] = useState<ReceivedMail[]>([]);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
// 메일 상세 모달 상태
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedMailId, setSelectedMailId] = useState<string>("");
// 검색 및 필터 상태
const [searchTerm, setSearchTerm] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
// 계정 목록 로드
useEffect(() => {
loadAccounts();
}, []);
// 계정 선택 시 메일 로드
useEffect(() => {
if (selectedAccountId) {
loadMails();
}
}, [selectedAccountId]);
// 자동 새로고침 (30초마다)
useEffect(() => {
if (!selectedAccountId) return;
const interval = setInterval(() => {
loadMails();
}, 30000); // 30초
return () => clearInterval(interval);
}, [selectedAccountId]);
const loadAccounts = async () => {
try {
const data = await getMailAccounts();
if (Array.isArray(data)) {
const activeAccounts = data.filter((acc) => acc.status === "active");
setAccounts(activeAccounts);
if (activeAccounts.length > 0 && !selectedAccountId) {
setSelectedAccountId(activeAccounts[0].id);
}
}
} catch (error) {
console.error("계정 로드 실패:", error);
}
};
const loadMails = async () => {
if (!selectedAccountId) return;
setLoading(true);
setTestResult(null);
try {
const data = await getReceivedMails(selectedAccountId, 50);
setMails(data);
} catch (error) {
console.error("메일 로드 실패:", error);
alert(
error instanceof Error
? error.message
: "메일을 불러오는데 실패했습니다."
);
setMails([]);
} finally {
setLoading(false);
}
};
const handleTestConnection = async () => {
if (!selectedAccountId) return;
setTesting(true);
setTestResult(null);
try {
const result = await testImapConnection(selectedAccountId);
setTestResult(result);
if (result.success) {
// 연결 성공 후 자동으로 메일 로드
setTimeout(() => loadMails(), 1000);
}
} catch (error) {
setTestResult({
success: false,
message:
error instanceof Error
? error.message
: "IMAP 연결 테스트 실패",
});
} finally {
setTesting(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}분 전`;
} else if (diffHours < 24) {
return `${diffHours}시간 전`;
} else if (diffDays < 7) {
return `${diffDays}일 전`;
} else {
return date.toLocaleDateString("ko-KR");
}
};
const handleMailClick = (mail: ReceivedMail) => {
setSelectedMailId(mail.id);
setIsDetailModalOpen(true);
};
const handleMailRead = () => {
// 메일을 읽었으므로 목록 새로고침
loadMails();
};
// 필터링 및 정렬된 메일 목록
const filteredAndSortedMails = React.useMemo(() => {
let result = [...mails];
// 검색
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
result = result.filter(
(mail) =>
mail.subject.toLowerCase().includes(searchLower) ||
mail.from.toLowerCase().includes(searchLower) ||
mail.preview.toLowerCase().includes(searchLower)
);
}
// 필터
if (filterStatus === "unread") {
result = result.filter((mail) => !mail.isRead);
} else if (filterStatus === "read") {
result = result.filter((mail) => mail.isRead);
} else if (filterStatus === "attachment") {
result = result.filter((mail) => mail.hasAttachments);
}
// 정렬
if (sortBy === "date-desc") {
result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} else if (sortBy === "date-asc") {
result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
} else if (sortBy === "from-asc") {
result.sort((a, b) => a.from.localeCompare(b.from));
} else if (sortBy === "from-desc") {
result.sort((a, b) => b.from.localeCompare(a.from));
}
return result;
}, [mails, searchTerm, filterStatus, sortBy]);
2025-10-01 16:15:53 +09:00
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
2025-10-01 17:01:31 +09:00
<p className="mt-2 text-gray-600">
IMAP으로
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadMails}
disabled={loading || !selectedAccountId}
>
<RefreshCw
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testing || !selectedAccountId}
>
{testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
</Button>
2025-10-01 16:15:53 +09:00
</div>
</div>
2025-10-01 17:01:31 +09:00
{/* 계정 선택 */}
2025-10-01 16:15:53 +09:00
<Card className="shadow-sm">
2025-10-01 17:01:31 +09:00
<CardContent className="p-4">
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
:
</label>
<select
value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value=""> </option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.email})
</option>
))}
</select>
</div>
{/* 연결 테스트 결과 */}
{testResult && (
<div
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
testResult.success
? "bg-green-50 text-green-800 border border-green-200"
: "bg-red-50 text-red-800 border border-red-200"
}`}
>
{testResult.success ? (
<CheckCircle className="w-5 h-5" />
) : (
<AlertCircle className="w-5 h-5" />
)}
<span>{testResult.message}</span>
</div>
)}
</CardContent>
</Card>
{/* 검색 및 필터 */}
{selectedAccountId && (
<Card className="shadow-sm">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-3">
{/* 검색 */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목, 발신자, 내용으로 검색..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
{/* 필터 */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value="all"></option>
<option value="unread"> </option>
<option value="read"></option>
<option value="attachment"> </option>
</select>
</div>
{/* 정렬 */}
<div className="flex items-center gap-2">
{sortBy.includes("desc") ? (
<SortDesc className="w-4 h-4 text-gray-500" />
) : (
<SortAsc className="w-4 h-4 text-gray-500" />
)}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
>
<option value="date-desc"> ()</option>
<option value="date-asc"> ()</option>
<option value="from-asc"> (A-Z)</option>
<option value="from-desc"> (Z-A)</option>
</select>
</div>
</div>
{/* 검색 결과 카운트 */}
{(searchTerm || filterStatus !== "all") && (
<div className="mt-3 text-sm text-gray-600">
{filteredAndSortedMails.length}
{searchTerm && (
<span className="ml-2">
(: <span className="font-medium text-orange-600">{searchTerm}</span>)
</span>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* 메일 목록 */}
{loading ? (
<Card className="shadow-sm">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-gray-600"> ...</span>
</CardContent>
</Card>
) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-white shadow-sm">
<CardContent className="pt-6">
2025-10-01 16:15:53 +09:00
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
2025-10-01 17:01:31 +09:00
<p className="text-gray-500 mb-4">
{!selectedAccountId
? "메일 계정을 선택하세요"
: searchTerm || filterStatus !== "all"
? "검색 결과가 없습니다"
: "받은 메일이 없습니다"}
2025-10-01 16:15:53 +09:00
</p>
2025-10-01 17:01:31 +09:00
{selectedAccountId && (
<Button
onClick={handleTestConnection}
variant="outline"
disabled={testing}
>
{testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
IMAP
</Button>
)}
</CardContent>
</Card>
) : (
<Card className="shadow-sm">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
<CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" />
({filteredAndSortedMails.length}/{mails.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{filteredAndSortedMails.map((mail) => (
2025-10-01 16:15:53 +09:00
<div
2025-10-01 17:01:31 +09:00
key={mail.id}
onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!mail.isRead ? "bg-blue-50/30" : ""
}`}
2025-10-01 16:15:53 +09:00
>
2025-10-01 17:01:31 +09:00
<div className="flex items-start gap-4">
{/* 읽음 표시 */}
<div className="flex-shrink-0 w-2 h-2 mt-2">
{!mail.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
{/* 메일 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span
className={`text-sm ${
mail.isRead
? "text-gray-600"
: "text-gray-900 font-semibold"
}`}
>
{mail.from}
</span>
<div className="flex items-center gap-2">
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
)}
<span className="text-xs text-gray-500">
{formatDate(mail.date)}
</span>
</div>
</div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-gray-700" : "text-gray-900 font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-gray-500 line-clamp-2">
{mail.preview}
</p>
</div>
2025-10-01 16:15:53 +09:00
</div>
</div>
))}
</div>
2025-10-01 17:01:31 +09:00
</CardContent>
</Card>
)}
2025-10-01 16:15:53 +09:00
{/* 안내 정보 */}
2025-10-01 17:01:31 +09:00
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 shadow-sm">
2025-10-01 16:15:53 +09:00
<CardHeader>
<CardTitle className="text-lg flex items-center">
2025-10-01 17:01:31 +09:00
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
! 🎉
2025-10-01 16:15:53 +09:00
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-4">
2025-10-01 17:01:31 +09:00
:
2025-10-01 16:15:53 +09:00
</p>
2025-10-01 17:01:31 +09:00
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p className="font-medium text-gray-800 mb-2">📬 </p>
<ul className="space-y-1 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>IMAP </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>/ </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
</ul>
</div>
<div>
<p className="font-medium text-gray-800 mb-2">📄 </p>
<ul className="space-y-1 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>HTML </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
</ul>
</div>
<div>
<p className="font-medium text-gray-800 mb-2">🔍 </p>
<ul className="space-y-1 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> (//)</span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> (/)</span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> (/)</span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> (30)</span>
</li>
</ul>
</div>
<div>
<p className="font-medium text-gray-800 mb-2">🔒 </p>
<ul className="space-y-1 text-sm text-gray-600">
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span>XSS (DOMPurify)</span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
<li className="flex items-start">
<span className="text-green-500 mr-2"></span>
<span> </span>
</li>
</ul>
</div>
</div>
2025-10-01 16:15:53 +09:00
</CardContent>
</Card>
</div>
2025-10-01 17:01:31 +09:00
{/* 메일 상세 모달 */}
<MailDetailModal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
accountId={selectedAccountId}
mailId={selectedMailId}
onMailRead={handleMailRead}
/>
2025-10-01 16:15:53 +09:00
</div>
);
}