fix(mail): 동기화 버튼 피드백·자동폴링·에러표시 + 메일 클릭 시 Gmail 열기
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
539ec2eb69
commit
effd72761e
@ -3,7 +3,7 @@ import { useParams, Link } from "react-router-dom";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil,
|
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";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
||||||
@ -499,12 +499,23 @@ function Contacts({ projectId }: { projectId: string }) {
|
|||||||
function MailTab({ projectId }: { projectId: string }) {
|
function MailTab({ projectId }: { projectId: string }) {
|
||||||
const { nameOf } = useDirectory();
|
const { nameOf } = useDirectory();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useQuery({ queryKey: ["mails", projectId], queryFn: () => getProjectMails(projectId) });
|
|
||||||
const [showHidden, setShowHidden] = useState(false);
|
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({
|
const sync = useMutation({
|
||||||
mutationFn: () => syncProjectMails(projectId),
|
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 />;
|
if (q.isLoading) return <LoadingState />;
|
||||||
const data = q.data;
|
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>
|
<span className="font-medium text-ink">@{data.domain}</span> 와(과) 주고받은 메일 · <span className="text-ink-muted">내가 수신·참조(CC)된 메일만</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-ink-muted mt-0.5">
|
<p className="text-[11px] text-ink-muted mt-0.5">
|
||||||
{data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : data.syncing ? "처음 동기화 중…" : "아직 동기화 안 됨"}
|
{busy ? "동기화 중…" : data.lastSyncedAt ? `마지막 동기화 ${formatDateTime(data.lastSyncedAt)}` : "아직 동기화 안 됨"}
|
||||||
{data.error && <span className="text-status-pending-fg"> · 일부 메일함 오류</span>}
|
{data.error && <span className="text-status-pending-fg" title={data.error}> · 동기화 오류(연동 설정 확인)</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
@ -537,8 +548,8 @@ function MailTab({ projectId }: { projectId: string }) {
|
|||||||
{showHidden ? "숨긴 메일 가리기" : `숨긴 메일 보기 (${hiddenCount})`}
|
{showHidden ? "숨긴 메일 가리기" : `숨긴 메일 보기 (${hiddenCount})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="secondary" icon={<RefreshCw size={14} className={sync.isPending ? "animate-spin" : ""} />}
|
<Button size="sm" variant="secondary" icon={<RefreshCw size={14} className={busy ? "animate-spin" : ""} />}
|
||||||
onClick={() => sync.mutate()} disabled={sync.isPending}>동기화</Button>
|
onClick={() => sync.mutate()} disabled={sync.isPending}>{busy ? "동기화 중" : "동기화"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{visible.length === 0 ? (
|
{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" → 표시용 짧은 이름(이름 또는 @앞)
|
// "Name <a@b>" 또는 "a@b" → 표시용 짧은 이름(이름 또는 @앞)
|
||||||
function addrName(a: string): string {
|
function addrName(a: string): string {
|
||||||
a = a.trim();
|
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">
|
<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} />}
|
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setOpen((o) => !o)} className="min-w-0 flex-1 text-left">
|
{/* 메일 누르면 본인 Gmail에서 바로 열림 */}
|
||||||
<span className="flex items-center gap-2">
|
<a href={gmailUrl(mail.messageId)} target="_blank" rel="noopener noreferrer" title="Gmail에서 열기" className="min-w-0 flex-1 group">
|
||||||
<span className="text-sm font-semibold text-ink truncate">{mail.subject || "(제목 없음)"}</span>
|
<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>}
|
{mail.hidden && <span className="text-[10px] bg-divider text-ink-muted rounded-pill px-1.5 py-0.5 shrink-0">숨김</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-ink-muted truncate mt-0.5">
|
<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 title={rcpt.full} className="cursor-help underline decoration-dotted decoration-ink-muted/40 underline-offset-2">{rcpt.short}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-sm text-ink-secondary truncate mt-1">{mail.snippet}</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">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
|
<span className="text-[11px] text-ink-muted tabular hidden md:block">{when}</span>
|
||||||
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
|
<button onClick={() => hide.mutate()} title={mail.hidden ? "다시 보이기" : "숨기기"}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user