184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
IconButton,
|
|
Menu,
|
|
MenuItem,
|
|
Paper,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { alpha, useTheme } from "@mui/material/styles";
|
|
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
|
|
|
import { api } from "../api";
|
|
import { JobApplication } from "../types";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
|
|
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
|
type Status = (typeof STATUSES)[number];
|
|
|
|
function normalizeStatus(status: string): Status | "Other" {
|
|
if (status === "Interviewing") return "Interview";
|
|
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
|
|
return "Other";
|
|
}
|
|
|
|
function toneColor(theme: any, status: Status | "Other"): string {
|
|
if (status === "Rejected") return theme.palette.error.main;
|
|
if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main;
|
|
if (status === "Offer") return theme.palette.success.main;
|
|
if (status === "Interview") return alpha(theme.palette.primary.main, 0.95);
|
|
return theme.palette.primary.main;
|
|
}
|
|
|
|
export default function KanbanBoard() {
|
|
const theme = useTheme();
|
|
const { t } = useI18n();
|
|
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
|
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
|
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.get<JobApplication[]>("/jobapplications/board").then((r) => setJobs(r.data));
|
|
}, []);
|
|
|
|
const groups = useMemo(() => {
|
|
const map = new Map<string, JobApplication[]>();
|
|
STATUSES.forEach((s) => map.set(s, []));
|
|
map.set("Other", []);
|
|
for (const j of jobs) {
|
|
const key = normalizeStatus(j.status);
|
|
map.get(key)!.push(j);
|
|
}
|
|
map.forEach((arr, k) => {
|
|
arr.sort((a, b) => +new Date(b.dateApplied) - +new Date(a.dateApplied));
|
|
map.set(k, arr);
|
|
});
|
|
return map;
|
|
}, [jobs]);
|
|
|
|
const onDropTo = async (status: Status) => {
|
|
if (!dragJobId) return;
|
|
setDragJobId(null);
|
|
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
|
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
|
};
|
|
|
|
const setStatus = async (id: number, status: Status) => {
|
|
await api.patch(`/jobapplications/${id}/status`, { status });
|
|
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
|
};
|
|
|
|
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
|
|
|
return (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
|
Drag cards between columns to update status.
|
|
</Typography>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
|
{STATUSES.map((status) => {
|
|
const c = toneColor(theme, status);
|
|
const list = groups.get(status) ?? [];
|
|
return (
|
|
<Paper
|
|
key={status}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={() => void onDropTo(status)}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: 3,
|
|
minHeight: 220,
|
|
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`,
|
|
background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06),
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
|
|
{status}
|
|
</Typography>
|
|
<Chip
|
|
size="small"
|
|
label={list.length}
|
|
sx={{
|
|
fontWeight: 800,
|
|
color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9),
|
|
backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12),
|
|
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
{list.map((j) => (
|
|
<Card
|
|
key={j.id}
|
|
draggable
|
|
onDragStart={() => setDragJobId(j.id)}
|
|
onDragEnd={() => setDragJobId(null)}
|
|
sx={{
|
|
cursor: "grab",
|
|
borderRadius: 3,
|
|
border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`,
|
|
background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)",
|
|
backdropFilter: "blur(8px)",
|
|
color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a",
|
|
}}
|
|
>
|
|
<CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
|
<Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}>
|
|
{j.company?.name ?? ""}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuJobId(j.id);
|
|
setMenuAnchor(e.currentTarget);
|
|
}}
|
|
>
|
|
<MoreHorizIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}>
|
|
{j.jobTitle}
|
|
</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
|
<Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} />
|
|
{j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
{list.length === 0 && (
|
|
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
|
Drop here
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
|
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
|
|
.filter((s) => s !== currentMenuStatus)
|
|
.map((s) => (
|
|
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
|
{t("jobTableSetStatus", { status: s })}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</Box>
|
|
);
|
|
}
|