Complete Gmail correspondence workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user