feat(redesign): migrate Jobs Board presentation (P5-04a)

This commit is contained in:
cesnimda
2026-09-29 07:59:50 +02:00
parent 7127005e48
commit 42a696bc86
24 changed files with 446 additions and 109 deletions
+126 -95
View File
@@ -1,8 +1,9 @@
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
@@ -15,6 +16,7 @@ import {
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
import ViewStateNotice from "./ViewStateNotice";
@@ -40,19 +42,6 @@ type Status = PipelineStatus;
// belong to no group, and only appears when something is actually in it.
type ColumnKey = PipelineGroup | "Other";
const TONE_PALETTE: Record<string, (theme: any) => string> = {
error: (theme) => (theme.vars?.palette ?? theme.palette).error.main,
warning: (theme) => (theme.vars?.palette ?? theme.palette).warning.main,
success: (theme) => (theme.vars?.palette ?? theme.palette).success.main,
info: (theme) => (theme.vars?.palette ?? theme.palette).info.main,
primary: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
default: (theme) => (theme.vars?.palette ?? theme.palette).primary.main,
};
function toneColor(theme: any, status: Status | "Other"): string {
return TONE_PALETTE[statusTone(status)](theme);
}
// Mockup's Applied-column cards carry small skill-tag pills ("React", "TS") sourced from the
// job's own `tags` field (JSON array string) -- no new data, just parsed for display.
function parseTags(raw?: string): string[] {
@@ -71,7 +60,7 @@ type PillTone = "error" | "warning" | "success" | "info";
// Built entirely from fields the board already fetches (needsFollowUp/followUpAt/salary/deadline)
// -- there's no interview-datetime field on the board's JobApplication shape, so an "Interview
// tomorrow 14:00"-style pill isn't reproducible; deadline is used as the closest real fallback.
function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { text: string; tone: PillTone } | null {
function cardPill(job: JobApplication, t: (key: any, params?: any) => string, language: string): { text: string; tone: PillTone } | null {
if (job.needsFollowUp) {
const due = job.followUpAt ? new Date(job.followUpAt) : null;
if (!due || due.getTime() <= Date.now()) return { text: t("kanbanFollowUpNow"), tone: "error" };
@@ -79,15 +68,18 @@ function cardPill(job: JobApplication, t: (key: any, params?: any) => string): {
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
}
if (job.salary) return { text: job.salary, tone: "success" };
if (job.deadline) return { text: new Date(job.deadline).toLocaleDateString(), tone: "info" };
if (job.deadline) return { text: t("jobDiscoveryDeadline", { date: new Date(job.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en") }), tone: "info" };
return null;
}
export default function KanbanBoard() {
const theme = useTheme();
const palette = theme.vars?.palette ?? theme.palette;
const customShadows = (theme.vars as any)?.customShadows ?? (theme as any).customShadows;
const { t } = useI18n();
const { t, language } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const focusAfterMoveRef = useRef<number | null>(null);
const restoredFocusRef = useRef<number | null>(null);
const [dragJobId, setDragJobId] = useState<number | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
const [moveError, setMoveError] = useState("");
@@ -109,6 +101,23 @@ export default function KanbanBoard() {
const jobs = jobsResource.data;
useEffect(() => {
if (jobsResource.loading || jobsResource.error) return;
const returnId = window.history.state?.jobBoardFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
const targetId = focusAfterMoveRef.current ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
if (targetId == null) return;
const card = document.querySelector<HTMLElement>(`[data-kanban-card-id="${targetId}"] [role="button"]`);
if (!card) return;
card.focus();
focusAfterMoveRef.current = null;
restoredFocusRef.current = targetId;
}, [jobs, jobsResource.loading, jobsResource.error, location.state]);
const openJob = (id: number) => {
window.history.replaceState({ ...window.history.state, jobBoardFocusId: id }, "");
navigate(`/jobs/${id}`, { state: { from: `${location.pathname}${location.search}`, focusJobId: id } });
};
const groups = useMemo(() => {
const map = new Map<ColumnKey, JobApplication[]>();
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
@@ -142,11 +151,13 @@ export default function KanbanBoard() {
setMoveError("");
try {
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
focusAfterMoveRef.current = movingJobId;
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) {
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
document.querySelector<HTMLElement>(`[data-kanban-card-id="${movingJobId}"] [role="button"]`)?.focus();
}
};
@@ -154,11 +165,13 @@ export default function KanbanBoard() {
setMoveError("");
try {
await api.patch(`/jobapplications/${id}/status`, { status });
focusAfterMoveRef.current = id;
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) {
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
setAnnouncement(t("kanbanMoveFailedAnnouncement"));
document.querySelector<HTMLElement>(`[data-kanban-card-id="${id}"] [role="button"]`)?.focus();
}
};
@@ -180,9 +193,16 @@ export default function KanbanBoard() {
return (
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
{t("kanbanHint")}
</Typography>
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 1.5, flexDirection: { xs: "column", sm: "row" }, mb: 2 }}>
<Box>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("kanbanHint")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("kanbanSeparateViews")}</Typography>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexShrink: 0 }}>
<Button component={RouterLink} to="/jobs" variant="outlined" size="small">{t("jobApplications")}</Button>
<Button variant="contained" size="small" aria-current="page" disabled>{t("kanbanBoard")}</Button>
</Box>
</Box>
<ViewStateNotice
loading={jobsResource.loading}
@@ -195,23 +215,23 @@ export default function KanbanBoard() {
{!jobsResource.loading && !jobsResource.error ? (
<Box
sx={{
display: { xs: "flex", md: "grid" },
display: { xs: "flex", lg: "grid" },
width: "100%",
maxWidth: "100%",
minWidth: 0,
gridTemplateColumns: { md: "repeat(3, 1fr)" },
gap: 2,
gridTemplateColumns: { lg: `repeat(${columns.length}, minmax(0, 1fr))` },
gap: 1.5,
alignItems: "start",
overflowX: { xs: "auto", md: "visible" },
scrollSnapType: { xs: "x mandatory", md: "none" },
pb: { xs: 1, md: 0 },
overflowX: { xs: "auto", lg: "visible" },
scrollSnapType: { xs: "x mandatory", lg: "none" },
pb: { xs: 1, lg: 0 },
WebkitOverflowScrolling: "touch",
}}
>
{columns.map(({ key, label, droppable }) => {
const list = groups.get(key) ?? [];
// Colour the column by its entry stage so the header dot still carries meaning.
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : palette.text.disabled;
const tone = droppable ? statusTone(GROUP_ENTRY_STATUS[key as PipelineGroup]) : "default";
const c = tone === "default" ? palette.text.secondary : palette[tone].main;
const isDragActive = dragJobId !== null;
const isActiveTarget = dragOverColumn === key;
return (
@@ -219,7 +239,7 @@ export default function KanbanBoard() {
key={key}
component="section"
role="group"
aria-label={`${label} column${droppable ? "" : ", not a drop target"}`}
aria-label={t(droppable ? "kanbanColumnLabel" : "kanbanColumnNotDropTarget", { label })}
aria-disabled={isDragActive && !droppable ? true : undefined}
tabIndex={isDragActive ? 0 : -1}
data-drop-state={isActiveTarget ? (droppable ? "active" : "invalid") : isDragActive ? (droppable ? "valid" : "invalid") : "idle"}
@@ -239,24 +259,25 @@ export default function KanbanBoard() {
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
sx={{
p: 1.5,
borderRadius: 3,
p: 1.25,
borderRadius: 2.5,
minHeight: 220,
flex: { xs: "0 0 85vw", md: "none" },
scrollSnapAlign: { xs: "start", md: "none" },
border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`,
outline: isActiveTarget ? `2px solid ${droppable ? palette.primary.main : palette.error.main}` : "none",
flex: { xs: "0 0 min(85vw, 340px)", lg: "none" },
scrollSnapAlign: { xs: "start", lg: "none" },
border: "1px solid",
borderColor: isActiveTarget ? (droppable ? "primary.main" : "error.main") : "divider",
outline: isActiveTarget ? "2px solid" : "none",
outlineColor: droppable ? "primary.main" : "error.main",
outlineOffset: 2,
opacity: isDragActive && !droppable ? 0.65 : 1,
backgroundColor: isActiveTarget && droppable ? palette.action.hover : palette.grey[100],
transition: "background-color .15s, border-color .15s, outline-color .15s, opacity .15s",
"&:focus-visible": { boxShadow: customShadows?.focus },
backgroundColor: isActiveTarget && droppable ? "action.hover" : "background.paper",
transition: "background-color .15s, border-color .15s, outline-color .15s",
"&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 2 },
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1.25, px: 0.25, gap: 1 }}>
<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 }}>
<Typography component="h2" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>
{label}
</Typography>
</Box>
@@ -267,7 +288,7 @@ export default function KanbanBoard() {
px: 0.75,
py: 0.15,
borderRadius: 999,
backgroundColor: palette.grey[300],
backgroundColor: "action.hover",
}}
>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
@@ -278,75 +299,76 @@ export default function KanbanBoard() {
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{list.map((j) => {
const pill = cardPill(j, t);
const pill = cardPill(j, t, language);
const tags = parseTags(j.tags);
// The column is a group, so each card names its own stage — grouping collapses
// the columns, not the information.
const cardStatus = normalizeStatus(j.status);
const cardColor = toneColor(theme, cardStatus);
const cardTone = statusTone(cardStatus);
const cardColor = cardTone === "default" ? palette.text.secondary : palette[cardTone].main;
return (
<Card
key={j.id}
component="article"
data-kanban-card-id={j.id}
draggable
role="button"
tabIndex={0}
aria-pressed={dragJobId === j.id}
aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })}
onKeyDown={(event) => {
if (event.key === "Escape" && dragJobId === j.id) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
} else if (event.key === " ") {
event.preventDefault();
const pickingUp = dragJobId !== j.id;
setDragJobId(pickingUp ? j.id : null);
setDragOverColumn(null);
setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled"));
}
}}
onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }}
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
sx={{
cursor: "grab",
borderRadius: 2.5,
borderLeft: `4px solid ${cardColor}`,
transition: "box-shadow .15s, transform .15s",
outline: dragJobId === j.id ? `2px solid ${palette.primary.main}` : "none",
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
borderLeft: `3px solid ${cardColor}`,
backgroundColor: "background.elevated",
boxShadow: "none",
transition: "border-color .15s, background-color .15s",
outline: dragJobId === j.id ? "2px solid" : "none",
outlineColor: "primary.main",
outlineOffset: 2,
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
"&:focus-visible": { boxShadow: customShadows?.focus },
"&:hover": { borderColor: "border.strong", backgroundColor: "action.hover" },
"&:active": { cursor: "grabbing" },
}}
>
<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 }}>
<CardContent sx={{ position: "relative", p: 1.25, "&:last-child": { pb: 1.25 } }}>
<Box
role="button"
tabIndex={0}
aria-pressed={dragJobId === j.id}
aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })}
onKeyDown={(event) => {
if (event.key === "Escape" && dragJobId === j.id) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
} else if (event.key === " ") {
event.preventDefault();
const pickingUp = dragJobId !== j.id;
setDragJobId(pickingUp ? j.id : null);
setDragOverColumn(null);
setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled"));
} else if (event.key === "Enter" && dragJobId == null) {
event.preventDefault(); openJob(j.id);
}
}}
onClick={() => { if (dragJobId == null) openJob(j.id); }}
sx={{ cursor: "pointer", borderRadius: 1, "&:focus-visible": { outline: "3px solid", outlineColor: "focus.main", outlineOffset: 3 } }}
>
<Typography sx={{ pr: 4.5, fontWeight: 700, lineHeight: 1.3, overflowWrap: "anywhere" }}>
{j.jobTitle}
</Typography>
<IconButton
size="small"
aria-label={`Change status for ${j.jobTitle}`}
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" }}>
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
</Typography>
<Box sx={{ mt: 0.75 }}>
<Chip
size="small"
label={statusLabel(t, j.status)}
variant="outlined"
sx={{
height: 22,
fontWeight: 700,
backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`,
color: cardColor,
borderColor: cardTone === "default" ? "divider" : cardColor,
color: cardTone === "default" ? "text.primary" : cardColor,
}}
/>
</Box>
@@ -358,7 +380,7 @@ export default function KanbanBoard() {
key={tag}
size="small"
label={tag}
sx={{ height: 22, backgroundColor: palette.action.hover, color: "primary.main", fontWeight: 700 }}
sx={{ height: 22, backgroundColor: "action.hover", color: "text.secondary", fontWeight: 600 }}
/>
))}
</Box>
@@ -372,25 +394,34 @@ export default function KanbanBoard() {
sx={{
height: 24,
fontWeight: 700,
backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`,
backgroundColor: "action.hover",
color: palette[pill.tone].main,
}}
/>
</Box>
)}
{j.daysSince != null ? (
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
{t("kanbanAppliedAgo", { days: j.daysSince })}
</Typography>
) : null}
{j.daysSince != null ? (
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
{t("kanbanAppliedAgo", { days: j.daysSince })}
</Typography>
) : null}
</Box>
<IconButton
size="small"
aria-label={`${t("jobTableQuickStatus")}: ${j.jobTitle}`}
onClick={(e) => { setMenuJobId(j.id); setMenuAnchor(e.currentTarget); }}
sx={{ position: "absolute", top: 5, right: 5, minWidth: { xs: 44, sm: 36 }, minHeight: { xs: 44, sm: 36 } }}
>
<MoreHorizIcon fontSize="small" />
</IconButton>
</CardContent>
</Card>
);
})}
{list.length === 0 && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
{t("kanbanDropHere")}
<Typography variant="body2" sx={{ color: "text.secondary", py: 2, px: 0.5 }}>
{dragJobId !== null && droppable ? t("kanbanDropHere") : t("kanbanEmptyColumn")}
</Typography>
)}
</Box>