Complete Gmail correspondence workflow

This commit is contained in:
2026-04-02 12:29:24 +02:00
parent 1f34eb42d2
commit 5cd34f17bb
10 changed files with 1390 additions and 145 deletions
@@ -10,10 +10,14 @@ import {
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
List,
ListItemButton,
ListItemText,
MenuItem,
Paper,
Select,
Tab,
Tabs,
TextField,
@@ -35,8 +39,10 @@ import {
GmailImportMessageResult,
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailRelinkResult,
GmailStatus,
GmailThreadRefreshResult,
GmailUnlinkResult,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs";
@@ -97,6 +103,10 @@ function formatReasonLabel(label: string) {
}
}
interface PagedResult<T> {
items: T[];
}
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
const theme = useTheme();
const { toast } = useToast();
@@ -120,6 +130,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
const [manageNote, setManageNote] = useState("");
const [manageSaving, setManageSaving] = useState(false);
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
@@ -157,6 +172,15 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
}, [jobId, toast]);
const loadAvailableJobs = useCallback(async () => {
try {
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
} catch {
setAvailableJobs([]);
}
}, [jobId]);
const linkedThreadIds = useMemo(
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
[messages],
@@ -210,7 +234,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
useEffect(() => {
void loadGmailStatus();
}, [loadGmailStatus]);
void loadAvailableJobs();
}, [loadAvailableJobs, loadGmailStatus]);
useEffect(() => {
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
@@ -367,6 +392,55 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
};
const openManageThread = (threadId: string) => {
setManageThreadId(threadId);
setManageTargetJobId(jobId);
setManageNote("");
};
const unlinkThread = async () => {
if (!manageThreadId) return;
setManageSaving(true);
try {
const res = await api.post<GmailUnlinkResult>("/gmail/unlink-thread", {
jobApplicationId: jobId,
threadId: manageThreadId,
note: manageNote.trim() || null,
nextDecision: "review",
});
await load();
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
} finally {
setManageSaving(false);
}
};
const relinkThread = async () => {
if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return;
setManageSaving(true);
try {
const res = await api.post<GmailRelinkResult>("/gmail/relink-thread", {
jobApplicationId: manageTargetJobId,
threadId: manageThreadId,
removeFromOtherJobs: true,
note: manageNote.trim() || null,
});
await load();
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
} finally {
setManageSaving(false);
}
};
return (
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
@@ -415,6 +489,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
{linkedThreadIds.slice(0, 6).map((threadId) => (
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
Manage {threadId}
</Button>
))}
{gmailStatus?.lastSyncStatus ? (
<Chip
size="small"
@@ -457,6 +536,44 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
<DialogTitle>Manage linked Gmail thread</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Unlink this thread from the current job, or move it to another existing job.
</Typography>
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
<TextField
label="Review note"
value={manageNote}
onChange={(event) => setManageNote(event.target.value)}
multiline
minRows={2}
placeholder="Why this thread should stay in review or move to another job."
/>
<FormControl fullWidth>
<InputLabel>Move to job</InputLabel>
<Select
value={String(manageTargetJobId)}
label="Move to job"
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
>
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
{availableJobs.map((item) => (
<MenuItem key={item.id} value={String(item.id)}>
{item.company?.name || "Unknown company"} {item.jobTitle}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent>