First Commit
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CorrespondenceMessage } 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 = 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 [rawEmail, setRawEmail] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
|
||||
setMessages(res.data);
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={importEmail}>
|
||||
Log Email
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user