511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Divider,
|
|
List,
|
|
ListItemButton,
|
|
ListItemText,
|
|
Paper,
|
|
Tab,
|
|
Tabs,
|
|
TextField,
|
|
ToggleButton,
|
|
ToggleButtonGroup,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { alpha, useTheme } from "@mui/material/styles";
|
|
|
|
import { api } from "../api";
|
|
import { useToast } from "../toast";
|
|
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
|
|
|
function parseRawEmail(raw: string): {
|
|
subject?: string;
|
|
date?: string;
|
|
from?: string;
|
|
to?: string;
|
|
body: string;
|
|
} {
|
|
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
|
const headers: Record<string, string> = {};
|
|
let i = 0;
|
|
|
|
for (; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.trim() === "") {
|
|
i++;
|
|
break;
|
|
}
|
|
|
|
const idx = line.indexOf(":");
|
|
if (idx <= 0) continue;
|
|
|
|
const k = line.slice(0, idx).trim().toLowerCase();
|
|
const v = line.slice(idx + 1).trim();
|
|
headers[k] = headers[k] ? `${headers[k]} ${v}` : v;
|
|
}
|
|
|
|
const body = lines.slice(i).join("\n").trim();
|
|
const subject = headers["subject"];
|
|
const from = headers["from"];
|
|
const to = headers["to"];
|
|
const dateRaw = headers["date"];
|
|
|
|
let iso: string | undefined;
|
|
if (dateRaw) {
|
|
const d = new Date(dateRaw);
|
|
if (!Number.isNaN(+d)) iso = d.toISOString();
|
|
}
|
|
|
|
return { subject, from, to, date: iso, body };
|
|
}
|
|
|
|
export default function Correspondence({ jobId }: { jobId: number }) {
|
|
const theme = useTheme();
|
|
const { toast } = useToast();
|
|
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
|
|
const [from, setFrom] = useState<"Me" | "Company">("Me");
|
|
const [text, setText] = useState("");
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [importTab, setImportTab] = useState(0);
|
|
const [rawEmail, setRawEmail] = useState("");
|
|
|
|
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
|
const [gmailLoading, setGmailLoading] = useState(false);
|
|
const [gmailQuery, setGmailQuery] = useState("");
|
|
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
|
|
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
|
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
|
|
setMessages(res.data);
|
|
}, [jobId]);
|
|
|
|
const loadGmailStatus = useCallback(async () => {
|
|
try {
|
|
setGmailLoading(true);
|
|
const res = await api.get<GmailStatus>("/gmail/status");
|
|
setGmailStatus(res.data);
|
|
} catch {
|
|
setGmailStatus({ connected: false });
|
|
} finally {
|
|
setGmailLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadGmailMessages = useCallback(async () => {
|
|
try {
|
|
setGmailMessagesLoading(true);
|
|
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
|
|
params: {
|
|
query: gmailQuery.trim() || undefined,
|
|
maxResults: 12,
|
|
},
|
|
});
|
|
setGmailMessages(res.data);
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data && typeof error.response.data === "string"
|
|
? error.response.data
|
|
: "Failed to load Gmail messages.";
|
|
toast(message, "error");
|
|
} finally {
|
|
setGmailMessagesLoading(false);
|
|
}
|
|
}, [gmailQuery, toast]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollTop = el.scrollHeight;
|
|
}, [messages.length]);
|
|
|
|
useEffect(() => {
|
|
if (!importOpen) return;
|
|
void loadGmailStatus();
|
|
}, [importOpen, loadGmailStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
|
void loadGmailMessages();
|
|
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
|
|
|
|
useEffect(() => {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const data = event.data as { source?: string; status?: string; message?: string };
|
|
if (data?.source !== "jobtracker-gmail-oauth") return;
|
|
if (data.status === "connected") {
|
|
toast(data.message || "Gmail connected.", "success");
|
|
void loadGmailStatus();
|
|
setImportTab(1);
|
|
void loadGmailMessages();
|
|
} else {
|
|
toast(data.message || "Gmail connection failed.", "error");
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [loadGmailMessages, loadGmailStatus, toast]);
|
|
|
|
const canSend = useMemo(() => text.trim().length > 0, [text]);
|
|
|
|
const send = async () => {
|
|
if (!canSend) return;
|
|
|
|
try {
|
|
await api.post("/correspondence", {
|
|
jobApplicationId: jobId,
|
|
from,
|
|
content: text,
|
|
});
|
|
|
|
setText("");
|
|
await load();
|
|
} catch {
|
|
toast("Failed to add message.", "error");
|
|
}
|
|
};
|
|
|
|
const importEmail = async () => {
|
|
const parsed = parseRawEmail(rawEmail);
|
|
if (!parsed.body && !parsed.subject && !rawEmail.trim()) {
|
|
toast("Paste an email first.", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.post("/correspondence", {
|
|
jobApplicationId: jobId,
|
|
from,
|
|
channel: "Email",
|
|
subject: parsed.subject ?? null,
|
|
content: parsed.body || rawEmail,
|
|
date: parsed.date ?? null,
|
|
});
|
|
|
|
setImportOpen(false);
|
|
setRawEmail("");
|
|
await load();
|
|
toast("Email logged.", "success");
|
|
} catch {
|
|
toast("Failed to import email.", "error");
|
|
}
|
|
};
|
|
|
|
const connectGmail = async () => {
|
|
try {
|
|
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
|
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
|
if (!popup) {
|
|
toast("Your browser blocked the Gmail popup.", "error");
|
|
}
|
|
} catch {
|
|
toast("Failed to start Gmail connection.", "error");
|
|
}
|
|
};
|
|
|
|
const disconnectGmail = async () => {
|
|
try {
|
|
await api.delete("/gmail/connection");
|
|
setGmailStatus({ connected: false });
|
|
setGmailMessages([]);
|
|
toast("Gmail disconnected.", "success");
|
|
} catch {
|
|
toast("Failed to disconnect Gmail.", "error");
|
|
}
|
|
};
|
|
|
|
const importGmailMessage = async (messageId: string) => {
|
|
try {
|
|
setImportingMessageId(messageId);
|
|
await api.post("/gmail/import", {
|
|
jobApplicationId: jobId,
|
|
messageId,
|
|
});
|
|
await load();
|
|
toast("Email imported from Gmail.", "success");
|
|
} catch (error: any) {
|
|
const message =
|
|
error?.response?.data && typeof error.response.data === "string"
|
|
? error.response.data
|
|
: "Failed to import Gmail message.";
|
|
toast(message, "error");
|
|
} finally {
|
|
setImportingMessageId(null);
|
|
}
|
|
};
|
|
|
|
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)",
|
|
}}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>
|
|
No messages yet.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
{messages.map((m) => {
|
|
const isMe = (m.from || "").toLowerCase() === "me";
|
|
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
|
|
return (
|
|
<Box
|
|
key={m.id}
|
|
sx={{
|
|
display: "flex",
|
|
justifyContent: isMe ? "flex-end" : "flex-start",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
maxWidth: "80%",
|
|
borderRadius: 3,
|
|
p: 1.25,
|
|
border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`,
|
|
background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1),
|
|
color: "text.primary",
|
|
}}
|
|
>
|
|
{m.subject ? (
|
|
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>
|
|
{m.subject}
|
|
</Typography>
|
|
) : null}
|
|
|
|
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>
|
|
{m.content}
|
|
</Typography>
|
|
|
|
<Typography variant="caption" sx={{ display: "block", mt: 0.75, color: "text.secondary" }}>
|
|
{isMe ? "Me" : "Company"}
|
|
{m.channel ? ` - ${m.channel}` : ""}
|
|
{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mt: 1.5, flexWrap: "wrap" }}>
|
|
<ToggleButtonGroup
|
|
exclusive
|
|
value={from}
|
|
onChange={(_, v) => {
|
|
if (v) setFrom(v);
|
|
}}
|
|
size="small"
|
|
>
|
|
<ToggleButton value="Me">Me</ToggleButton>
|
|
<ToggleButton value="Company">Company</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>
|
|
Import Email
|
|
</Button>
|
|
|
|
<Box sx={{ flex: "1 1 280px" }}>
|
|
<textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
placeholder="Log an email/call note..."
|
|
style={{
|
|
width: "100%",
|
|
minHeight: 70,
|
|
resize: "vertical",
|
|
padding: 10,
|
|
borderRadius: 12,
|
|
border:
|
|
theme.palette.mode === "dark"
|
|
? "1px solid rgba(148,163,184,0.25)"
|
|
: "1px solid rgba(15,23,42,0.12)",
|
|
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
|
|
color: theme.palette.text.primary,
|
|
fontFamily: "inherit",
|
|
fontSize: 14,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Button variant="contained" onClick={send} disabled={!canSend}>
|
|
Add
|
|
</Button>
|
|
</Box>
|
|
|
|
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
|
<DialogTitle>Import Email</DialogTitle>
|
|
<DialogContent>
|
|
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
|
|
<Tab label="Paste email" />
|
|
<Tab label="Gmail" />
|
|
</Tabs>
|
|
|
|
{importTab === 0 ? (
|
|
<>
|
|
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
|
Paste raw email text (headers optional). We parse Subject and Date when present.
|
|
</Typography>
|
|
<textarea
|
|
value={rawEmail}
|
|
onChange={(e) => setRawEmail(e.target.value)}
|
|
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
|
|
style={{
|
|
width: "100%",
|
|
minHeight: 220,
|
|
resize: "vertical",
|
|
padding: 12,
|
|
borderRadius: 12,
|
|
border:
|
|
theme.palette.mode === "dark"
|
|
? "1px solid rgba(148,163,184,0.25)"
|
|
: "1px solid rgba(15,23,42,0.12)",
|
|
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
|
|
color: theme.palette.text.primary,
|
|
fontFamily: "inherit",
|
|
fontSize: 14,
|
|
}}
|
|
/>
|
|
</>
|
|
) : (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{gmailLoading
|
|
? "Checking connection..."
|
|
: gmailStatus?.connected
|
|
? `Connected as ${gmailStatus.gmailAddress}`
|
|
: "Connect your Gmail account to browse recent emails."}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{gmailStatus?.connected ? (
|
|
<>
|
|
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
|
|
Refresh
|
|
</Button>
|
|
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>
|
|
Disconnect
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="contained" onClick={() => void connectGmail()}>
|
|
Connect Gmail
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{gmailStatus?.connected ? (
|
|
<>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<TextField
|
|
label="Search Gmail"
|
|
value={gmailQuery}
|
|
onChange={(e) => setGmailQuery(e.target.value)}
|
|
placeholder="from:company@example.com OR interview"
|
|
size="small"
|
|
fullWidth
|
|
/>
|
|
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
|
|
Search
|
|
</Button>
|
|
</Box>
|
|
{gmailStatus.lastSyncedAt ? (
|
|
<Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" />
|
|
) : null}
|
|
<Paper variant="outlined" sx={{ maxHeight: 360, overflowY: "auto" }}>
|
|
{gmailMessagesLoading ? (
|
|
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}>
|
|
<CircularProgress size={28} />
|
|
</Box>
|
|
) : gmailMessages.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
|
|
) : (
|
|
<List disablePadding>
|
|
{gmailMessages.map((message, index) => (
|
|
<React.Fragment key={message.id}>
|
|
{index > 0 ? <Divider /> : null}
|
|
<ListItemButton sx={{ alignItems: "flex-start", py: 1.5 }}>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Typography sx={{ fontWeight: 800 }}>{message.subject || "(No subject)"}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{message.date ? new Date(message.date).toLocaleString() : ""}
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Box sx={{ mt: 0.5 }}>
|
|
<Typography variant="body2" sx={{ color: "text.primary" }}>
|
|
From: {message.from || "Unknown"}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
|
|
{message.snippet}
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
disabled={importingMessageId === message.id}
|
|
onClick={() => void importGmailMessage(message.id)}
|
|
>
|
|
{importingMessageId === message.id ? "Importing..." : "Import"}
|
|
</Button>
|
|
</ListItemButton>
|
|
</React.Fragment>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Paper>
|
|
</>
|
|
) : null}
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setImportOpen(false)}>Close</Button>
|
|
{importTab === 0 ? (
|
|
<Button variant="contained" onClick={importEmail}>
|
|
Log Email
|
|
</Button>
|
|
) : null}
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|