refactor, security updates, cv extraction upgrades

This commit is contained in:
2026-04-11 01:34:32 +02:00
parent 806b200ac5
commit 27fd70a2d7
59 changed files with 6817 additions and 1561 deletions
+112 -91
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import {
Box,
@@ -15,8 +15,10 @@ import { alpha, useTheme } from "@mui/material/styles";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { api } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
type Status = (typeof STATUSES)[number];
@@ -57,14 +59,23 @@ function statusLabel(t: (key: any, params?: any) => string, status: Status): str
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 jobsResource = useViewResource(
async () => {
const response = await api.get<JobApplication[]>("/jobapplications/board");
return response.data;
},
{
initialData: [],
errorMessage: "Unable to load the board right now.",
deps: [],
},
);
const jobs = jobsResource.data;
const groups = useMemo(() => {
const map = new Map<string, JobApplication[]>();
@@ -85,12 +96,12 @@ export default function KanbanBoard() {
if (!dragJobId) return;
setDragJobId(null);
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
jobsResource.setData((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)));
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
};
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
@@ -101,92 +112,102 @@ export default function KanbanBoard() {
{t("kanbanHint")}
</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" }}>
{statusLabel(t, 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>
<ViewStateNotice
loading={jobsResource.loading}
error={jobsResource.error}
title="Unable to load the kanban board"
description="The board could not reach the API."
onRetry={jobsResource.reload}
/>
<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 }}>
{t("kanbanDropHere")}
{!jobsResource.loading && !jobsResource.error ? (
<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" }}>
{statusLabel(t, status)}
</Typography>
)}
</Box>
</Paper>
);
})}
</Box>
<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 }}>
{t("kanbanDropHere")}
</Typography>
)}
</Box>
</Paper>
);
})}
</Box>
) : null}
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)