update kanban styling, add more detailed error log for gmail

This commit is contained in:
cesnimda
2026-03-21 19:22:02 +01:00
parent 7211d67c62
commit 793ed6eb65
5 changed files with 210 additions and 134 deletions
+26 -2
View File
@@ -97,6 +97,8 @@ public sealed class GmailController : ControllerBase
[HttpPost("import")] [HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{
try
{ {
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required."); if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");
@@ -117,6 +119,7 @@ public sealed class GmailController : ControllerBase
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty; var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase); var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
var message = new Correspondence var message = new Correspondence
{ {
@@ -126,18 +129,39 @@ public sealed class GmailController : ControllerBase
Channel = "Email", Channel = "Email",
ExternalMessageId = detail.Id, ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText, Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = detail.Date?.LocalDateTime ?? DateTime.Now, Date = messageDate,
}; };
_db.Correspondences.Add(message); _db.Correspondences.Add(message);
if (job.Company is not null) if (job.Company is not null)
{ {
job.Company.LastContactedAt = DateTime.UtcNow; job.Company.LastContactedAt = messageDate;
}
if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value))
{
var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}";
job.ResponseReceived = true;
job.ResponseDate = messageDate;
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "ReplyReceived",
OldValue = oldResponse,
NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
Note = detail.Subject,
At = messageDate
});
} }
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
return Ok(message); return Ok(message);
} }
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
private string GetRequiredOwnerUserId() private string GetRequiredOwnerUserId()
{ {
@@ -236,8 +236,12 @@ export default function Correspondence({ jobId }: { jobId: number }) {
}); });
await load(); await load();
toast("Email imported from Gmail.", "success"); toast("Email imported from Gmail.", "success");
} catch { } catch (error: any) {
toast("Failed to import Gmail message.", "error"); const message =
error?.response?.data && typeof error.response.data === "string"
? error.response.data
: "Failed to import Gmail message.";
toast(message, "error");
} finally { } finally {
setImportingMessageId(null); setImportingMessageId(null);
} }
@@ -303,8 +307,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
sx={{ display: "block", mt: 0.75, color: "text.secondary" }} sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
> >
{isMe ? "Me" : "Company"} {isMe ? "Me" : "Company"}
{m.channel ? ` · ${m.channel}` : ""} {m.channel ? ` · ${m.channel}` : ""}
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""} {m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@@ -110,7 +110,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<JobFlowBar job={job} /> <JobFlowBar job={job} history={history} />
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}> <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Overview" /> <Tab label="Overview" />
<Tab label="Correspondence" /> <Tab label="Correspondence" />
+137 -91
View File
@@ -1,60 +1,141 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Box, Tooltip, Typography, useTheme } from "@mui/material"; import { Box, Chip, Typography, useTheme } from "@mui/material";
import { alpha } from "@mui/material/styles";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline"; import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
import ForumIcon from "@mui/icons-material/Forum";
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
import ForumIcon from "@mui/icons-material/Forum";
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; import EmojiEventsIcon from "@mui/icons-material/EmojiEvents";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { alpha } from "@mui/material/styles";
import { JobApplication } from "../types"; import { JobApplication } from "../types";
type HistoryEvent = {
id: number;
type: string;
oldValue?: string;
newValue?: string;
note?: string;
at: string;
};
type FlowItem = {
key: string;
label: string;
at: Date;
color: string;
icon: React.ReactNode;
};
function normalizeStatus(status?: string): string { function normalizeStatus(status?: string): string {
if (!status) return "Applied"; if (!status) return "Applied";
if (status === "Interviewing") return "Interview"; if (status === "Interviewing") return "Interview";
return status; return status;
} }
function isTerminalRejected(status: string) { function parseResponseDate(value?: string): Date | null {
return status === "Rejected" || status === "Ghosted"; if (!value) return null;
const parts = value.split(":");
const raw = parts.length > 1 ? parts.slice(1).join(":") : "";
const d = new Date(raw);
return Number.isNaN(+d) ? null : d;
} }
export default function JobFlowBar({ job }: { job: JobApplication | null }) { function firstStatusChange(history: HistoryEvent[], status: string): Date | null {
const match = history.find((item) => item.type === "StatusChanged" && normalizeStatus(item.newValue) === status);
if (!match) return null;
const d = new Date(match.at);
return Number.isNaN(+d) ? null : d;
}
function firstResponse(history: HistoryEvent[], job: JobApplication): Date | null {
if (job.responseDate) {
const d = new Date(job.responseDate);
if (!Number.isNaN(+d)) return d;
}
const explicit = history.find((item) => item.type === "ReplyReceived");
if (explicit) {
const d = new Date(explicit.at);
if (!Number.isNaN(+d)) return d;
}
const updated = history.find((item) => item.type === "ResponseUpdated");
if (!updated) return null;
return parseResponseDate(updated.newValue) ?? new Date(updated.at);
}
export default function JobFlowBar({ job, history = [] }: { job: JobApplication | null; history?: HistoryEvent[] }) {
const theme = useTheme(); const theme = useTheme();
const steps = useMemo(
() =>
[
{ key: "applied" as const, label: "Applied", color: theme.palette.info.main, icon: <WorkOutlineIcon fontSize="inherit" /> },
{ key: "interview" as const, label: "Interview", color: theme.palette.primary.main, icon: <ForumIcon fontSize="inherit" /> },
{ key: "reply" as const, label: "Reply", color: theme.palette.success.main, icon: <MarkEmailReadIcon fontSize="inherit" /> },
{ key: "offer" as const, label: "Offer", color: theme.palette.success.dark, icon: <EmojiEventsIcon fontSize="inherit" /> },
{ key: "outcome" as const, label: "Outcome", color: theme.palette.text.secondary, icon: <EmojiEventsIcon fontSize="inherit" /> },
] as const,
[theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.success.dark, theme.palette.text.secondary],
);
if (!job) return null; const items = useMemo(() => {
if (!job) return [] as FlowItem[];
const status = normalizeStatus(job.status); const appliedAt = new Date(job.dateApplied);
const reply = Boolean(job.responseReceived); const replyAt = firstResponse(history, job);
const rejected = isTerminalRejected(status); const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null);
const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null);
const rejectedLabel = normalizeStatus(job.status) === "Ghosted" ? "Ghosted" : "Rejected";
const rejectedAt = firstStatusChange(history, "Rejected")
|| firstStatusChange(history, "Ghosted")
|| ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null);
const activeIndex = (() => { const next: FlowItem[] = [
if (rejected) return 4; {
if (status === "Offer") return 3; key: "applied",
if (reply) return 2; label: "Applied",
if (status === "Interview") return 1; at: appliedAt,
return 0; color: theme.palette.info.main,
})(); icon: <WorkOutlineIcon fontSize="small" />,
},
];
const breakIndex = rejected ? activeIndex : null; if (replyAt) {
const visibleSteps = breakIndex !== null ? steps.slice(0, breakIndex + 1) : steps; next.push({
key: "reply",
label: "Reply",
at: replyAt,
color: theme.palette.success.main,
icon: <MarkEmailReadIcon fontSize="small" />,
});
}
const segCount = visibleSteps.length; if (interviewAt) {
const segWidth = `${100 / segCount}%`; next.push({
key: "interview",
label: "Interview",
at: interviewAt,
color: theme.palette.primary.main,
icon: <ForumIcon fontSize="small" />,
});
}
if (offerAt) {
next.push({
key: "offer",
label: "Offer",
at: offerAt,
color: theme.palette.success.dark,
icon: <EmojiEventsIcon fontSize="small" />,
});
} else if (rejectedAt) {
next.push({
key: normalizeStatus(job.status).toLowerCase(),
label: rejectedLabel,
at: rejectedAt,
color: theme.palette.error.main,
icon: <CloseIcon fontSize="small" />,
});
}
return next
.filter((item) => !Number.isNaN(+item.at))
.sort((a, b) => +a.at - +b.at)
.filter((item, index, arr) => index === arr.findIndex((other) => other.key === item.key));
}, [history, job, theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.dark, theme.palette.success.main]);
if (!job || items.length === 0) return null;
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
@@ -65,70 +146,35 @@ export default function JobFlowBar({ job }: { job: JobApplication | null }) {
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
borderRadius: 999, flexWrap: "wrap",
overflow: "hidden", gap: 1,
alignItems: "center",
p: 1.25,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`, border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
height: 44, background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.42)" : "rgba(248,250,252,0.92)",
background: alpha("#fff", 0.65),
}} }}
> >
{visibleSteps.map((s, idx) => { {items.map((item, index) => (
const filled = idx <= activeIndex && breakIndex === null; <React.Fragment key={`${item.key}-${item.at.toISOString()}`}>
const terminalFilled = breakIndex !== null && idx < breakIndex; <Chip
const isBreak = breakIndex !== null && idx === breakIndex; icon={item.icon as React.ReactElement}
label={`${item.label} · ${item.at.toLocaleDateString()}`}
const bg = filled || terminalFilled ? s.color : alpha(s.color, 0.12);
const fg = filled || terminalFilled ? "#fff" : alpha(theme.palette.text.primary, 0.6);
const title = (() => {
if (isBreak) return `${status} (stopped here)`;
if (idx === 2 && reply) return "Reply received";
return s.label;
})();
return (
<Tooltip key={s.key} title={title}>
<Box
sx={{ sx={{
width: segWidth, fontWeight: 800,
display: "flex", color: theme.palette.getContrastText(item.color),
alignItems: "center", backgroundColor: item.color,
justifyContent: "center", borderRadius: 2,
position: "relative", px: 0.5,
background: isBreak ? theme.palette.error.main : bg,
color: "#fff",
borderRight: idx === visibleSteps.length - 1 ? "none" : `1px solid ${alpha("#fff", 0.35)}`,
userSelect: "none",
}} }}
> />
{isBreak ? ( {index < items.length - 1 ? (
<CloseIcon fontSize="inherit" /> <Typography sx={{ color: "text.secondary", fontWeight: 900 }}>
) : ( ?
<Box sx={{ display: "inline-flex", alignItems: "center", gap: 1, color: fg, fontWeight: 900 }}> </Typography>
<Box sx={{ fontSize: 18, display: "inline-flex" }}>{s.icon}</Box>
<span style={{ fontSize: 13 }}>{s.key === "outcome" && rejected ? status : s.label}</span>
</Box>
)}
{isBreak ? (
<Box
sx={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 900,
letterSpacing: "0.02em",
}}
>
{status} <span style={{ marginLeft: 8, display: "inline-flex" }}><CloseIcon fontSize="inherit" /></span>
</Box>
) : null} ) : null}
</Box> </React.Fragment>
</Tooltip> ))}
);
})}
</Box> </Box>
</Box> </Box>
); );
@@ -133,6 +133,7 @@ export default function KanbanBoard() {
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`, border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.55)" : "rgba(255,255,255,0.8)", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.55)" : "rgba(255,255,255,0.8)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
color: theme.palette.text.primary,
}} }}
> >
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}> <CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
@@ -142,6 +143,7 @@ export default function KanbanBoard() {
</Typography> </Typography>
<IconButton <IconButton
size="small" size="small"
sx={{ color: theme.palette.text.primary }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setMenuJobId(j.id); setMenuJobId(j.id);