fix(mail): 동기화 버튼 피드백·자동폴링·에러표시 + 메일 클릭 시 Gmail 열기
All checks were successful
build-and-push / build (push) Successful in 31s

- 동기화 클릭 시 즉시 '동기화 중' 표시 + 30초간 자동 폴링(syncing 동안 3s refetch),
  실패 시 알림, 동기화 오류(연동 설정)도 표기
- 메일 제목 클릭 → 본인 Gmail에서 해당 메일 열기(rfc822msgid), 외부링크 아이콘
- 펼침(자세히)은 좌측 ▸ 버튼으로 분리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 14:01:35 +09:00
parent 539ec2eb69
commit effd72761e

View File

@ -3,7 +3,7 @@ import { useParams, Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil,
Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye,
Mail, ChevronDown, ChevronRight, RefreshCw, EyeOff, Eye, ExternalLink,
} from "lucide-react";
import {
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
@ -499,12 +499,23 @@ function Contacts({ projectId }: { projectId: string }) {
function MailTab({ projectId }: { projectId: string }) {
const { nameOf } = useDirectory();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["mails", projectId], queryFn: () => getProjectMails(projectId) });
const [showHidden, setShowHidden] = useState(false);
const [polling, setPolling] = useState(false);
const q = useQuery({
queryKey: ["mails", projectId],
queryFn: () => getProjectMails(projectId),
refetchInterval: (query) => (polling || query.state.data?.syncing ? 3000 : false),
});
const sync = useMutation({
mutationFn: () => syncProjectMails(projectId),
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["mails", projectId] }), 2500),
onMutate: () => setPolling(true),
onError: () => { setPolling(false); alert("동기화 요청에 실패했습니다. 잠시 후 다시 시도해 주세요."); },
onSettled: () => {
qc.invalidateQueries({ queryKey: ["mails", projectId] });
setTimeout(() => setPolling(false), 30000); // 백필 동안 자동 폴링 후 중단
},
});
const busy = sync.isPending || polling || !!q.data?.syncing;
if (q.isLoading) return <LoadingState />;
const data = q.data;
@ -527,8 +538,8 @@ function MailTab({ projectId }: { projectId: string }) {
<span className="font-medium text-ink">@{data.domain}</span> () · <span className="text-ink-muted"> ·(CC) </span>
</p>
<p className="text-[11px] text-ink-muted mt-0.5">
{data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : data.syncing ? "처음 동기화 중…" : "아직 동기화 안 됨"}
{data.error && <span className="text-status-pending-fg"> · </span>}
{busy ? "동기화 중…" : data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : "아직 동기화 안 됨"}
{data.error && <span className="text-status-pending-fg" title={data.error}> · ( )</span>}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
@ -537,8 +548,8 @@ function MailTab({ projectId }: { projectId: string }) {
{showHidden ? "숨긴 메일 가리기" : `숨긴 메일 보기 (${hiddenCount})`}
</button>
)}
<Button size="sm" variant="secondary" icon={<RefreshCw size={14} className={sync.isPending ? "animate-spin" : ""} />}
onClick={() => sync.mutate()} disabled={sync.isPending}></Button>
<Button size="sm" variant="secondary" icon={<RefreshCw size={14} className={busy ? "animate-spin" : ""} />}
onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button>
</div>
</div>
{visible.length === 0 ? (
@ -553,6 +564,11 @@ function MailTab({ projectId }: { projectId: string }) {
);
}
// RFC822 Message-ID로 보는 사람 본인 Gmail에서 해당 메일을 연다.
function gmailUrl(messageId: string): string {
return `https://mail.google.com/mail/u/0/#search/rfc822msgid:${encodeURIComponent(messageId)}`;
}
// "Name <a@b>" 또는 "a@b" → 표시용 짧은 이름(이름 또는 @앞)
function addrName(a: string): string {
a = a.trim();
@ -589,9 +605,11 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
<button onClick={() => setOpen((o) => !o)} className="mt-0.5 text-ink-muted shrink-0 p-1 rounded hover:bg-canvas">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<button onClick={() => setOpen((o) => !o)} className="min-w-0 flex-1 text-left">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink truncate">{mail.subject || "(제목 없음)"}</span>
{/* 메일 누르면 본인 Gmail에서 바로 열림 */}
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
<span className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-ink truncate group-hover:text-navy">{mail.subject || "(제목 없음)"}</span>
<ExternalLink size={12} className="shrink-0 text-ink-muted opacity-0 group-hover:opacity-100" />
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0"></span>}
</span>
<span className="block text-xs text-ink-muted truncate mt-0.5">
@ -599,7 +617,7 @@ function MailRow({ projectId, mail, nameOf }: { projectId: string; mail: Project
<span title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
</span>
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</span>
</button>
</a>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}