182 lines
5.6 KiB
TypeScript
182 lines
5.6 KiB
TypeScript
import React, { useMemo } from "react";
|
|
|
|
import { Box, Chip, Typography, useTheme } from "@mui/material";
|
|
import { alpha } from "@mui/material/styles";
|
|
|
|
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
|
import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead";
|
|
import ForumIcon from "@mui/icons-material/Forum";
|
|
import EmojiEventsIcon from "@mui/icons-material/EmojiEvents";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
|
|
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 {
|
|
if (!status) return "Applied";
|
|
if (status === "Interviewing") return "Interview";
|
|
return status;
|
|
}
|
|
|
|
function parseResponseDate(value?: string): Date | null {
|
|
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;
|
|
}
|
|
|
|
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 items = useMemo(() => {
|
|
if (!job) return [] as FlowItem[];
|
|
|
|
const appliedAt = new Date(job.dateApplied);
|
|
const replyAt = firstResponse(history, job);
|
|
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 next: FlowItem[] = [
|
|
{
|
|
key: "applied",
|
|
label: "Applied",
|
|
at: appliedAt,
|
|
color: theme.palette.info.main,
|
|
icon: <WorkOutlineIcon fontSize="small" />,
|
|
},
|
|
];
|
|
|
|
if (replyAt) {
|
|
next.push({
|
|
key: "reply",
|
|
label: "Reply",
|
|
at: replyAt,
|
|
color: theme.palette.success.main,
|
|
icon: <MarkEmailReadIcon fontSize="small" />,
|
|
});
|
|
}
|
|
|
|
if (interviewAt) {
|
|
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 (
|
|
<Box sx={{ mb: 2 }}>
|
|
<Typography variant="overline" sx={{ display: "block", mb: 0.75 }}>
|
|
Flow
|
|
</Typography>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 1,
|
|
alignItems: "center",
|
|
p: 1.25,
|
|
borderRadius: 3,
|
|
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
|
|
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.42)" : "rgba(248,250,252,0.92)",
|
|
}}
|
|
>
|
|
{items.map((item, index) => (
|
|
<React.Fragment key={`${item.key}-${item.at.toISOString()}`}>
|
|
<Chip
|
|
icon={item.icon as React.ReactElement}
|
|
label={`${item.label} - ${item.at.toLocaleDateString()}`}
|
|
sx={{
|
|
fontWeight: 800,
|
|
color: theme.palette.getContrastText(item.color),
|
|
backgroundColor: item.color,
|
|
borderRadius: 2,
|
|
px: 0.5,
|
|
}}
|
|
/>
|
|
{index < items.length - 1 ? (
|
|
<Typography sx={{ color: "text.secondary", fontWeight: 900 }}>
|
|
{"->"}
|
|
</Typography>
|
|
) : null}
|
|
</React.Fragment>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|