7cfbdf504a
First pass of the /frontend-design overhaul against the mockups at
F:\Pictures\website\jobtracker\new. Two highest-leverage gaps from the
backlog note ("dark sidebar, KPI cards, exact status colours"):
- AppShell: nav rail is now a fixed dark navy (#0f172a) regardless of
the app's light/dark theme toggle, matching the mockup's signature
look -- selected item gets an indigo-tinted pill + icon accent,
muted slate text for the rest. Kept icon+label rows (mockup's sidebar
is text-only) since the existing collapsed-sidebar mode depends on
icons; that's a deliberate deviation, not an oversight.
- JobbjaktMark: replaced the briefcase glyph with the gradient
checkmark-in-square mark used throughout the mockups (hero, dashboard,
kanban) -- also fixed a latent SVG gradient id collision across
multiple rendered instances via useId().
- KanbanBoard: mockup uses color sparingly (a small dot in the column
header, a 4px accent on the card's left edge) rather than tinting the
whole column/card background as the previous version did. Reworked
to match; also swapped card title/subtitle order (job title bold,
company/location as subtitle) per the mockup.
Remaining for follow-up passes: Dashboard KPI card layout and the job
workspace (candidate-fit ring, AI summary card) -- both structurally
close already but not yet pixel-matched.
Verified: `next build` clean, all 57 frontend tests green, dark
sidebar confirmed live (computed bg #0f172a) against a running dev
server with light content mode forced.
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
import React, { useMemo, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Card,
|
|
CardContent,
|
|
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 ViewStateNotice from "./ViewStateNotice";
|
|
import { JobApplication } from "../types";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useViewResource } from "../hooks/useViewResource";
|
|
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
|
|
|
const STATUSES = PIPELINE_STATUSES;
|
|
type Status = PipelineStatus;
|
|
|
|
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
|
error: (theme) => theme.palette.error.main,
|
|
warning: (theme) => theme.palette.warning.main,
|
|
success: (theme) => theme.palette.success.main,
|
|
info: (theme) => alpha(theme.palette.primary.main, 0.95),
|
|
primary: (theme) => theme.palette.primary.main,
|
|
default: (theme) => theme.palette.primary.main,
|
|
};
|
|
|
|
function toneColor(theme: any, status: Status | "Other"): string {
|
|
return TONE_PALETTE[statusTone(status)](theme);
|
|
}
|
|
|
|
export default function KanbanBoard() {
|
|
const theme = useTheme();
|
|
const { t } = useI18n();
|
|
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
|
const [menuJobId, setMenuJobId] = useState<number | null>(null);
|
|
|
|
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[]>();
|
|
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 });
|
|
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 });
|
|
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 ?? "");
|
|
|
|
return (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
|
{t("kanbanHint")}
|
|
</Typography>
|
|
|
|
<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}
|
|
/>
|
|
|
|
{!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",
|
|
borderColor: "divider",
|
|
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
|
{statusLabel(t, status)}
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
|
{list.length}
|
|
</Typography>
|
|
</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: 2.5,
|
|
borderLeft: `4px solid ${c}`,
|
|
boxShadow: theme.palette.mode === "dark" ? "none" : "0 1px 3px rgba(15,23,42,0.06)",
|
|
}}
|
|
>
|
|
<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 }}>
|
|
{j.jobTitle}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuJobId(j.id);
|
|
setMenuAnchor(e.currentTarget);
|
|
}}
|
|
>
|
|
<MoreHorizIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.75 }}>
|
|
{j.daysSince}d
|
|
</Typography>
|
|
</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)
|
|
.filter((s) => s !== currentMenuStatus)
|
|
.map((s) => (
|
|
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
|
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</Box>
|
|
);
|
|
}
|