Files
jobtrackingapp/job-tracker-ui/src/components/KanbanBoard.tsx
T
cesnimda 7cfbdf504a
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
feat(ui): dark navy sidebar + restrained kanban status colors
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.
2026-07-12 01:45:20 +02:00

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>
);
}