459 lines
22 KiB
TypeScript
459 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Card,
|
|
CardContent,
|
|
Chip,
|
|
IconButton,
|
|
ListSubheader,
|
|
Menu,
|
|
MenuItem,
|
|
Paper,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { useTheme } from "@mui/material/styles";
|
|
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
import { JobApplication } from "../types";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useViewResource } from "../hooks/useViewResource";
|
|
import { jobsQueryFromSearch } from "../jobsQuery";
|
|
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
|
import {
|
|
GROUP_ENTRY_STATUS,
|
|
PIPELINE_GROUPS,
|
|
PIPELINE_STATUSES,
|
|
PipelineGroup,
|
|
PipelineStatus,
|
|
groupOf,
|
|
normalizeStatus,
|
|
statusLabel,
|
|
statusTone,
|
|
} from "../pipeline";
|
|
|
|
type Status = PipelineStatus;
|
|
|
|
// The board shows one column per group rather than one per stage: ten columns is unreadable, and
|
|
// the stage detail is preserved on each card's chip. "Other" collects custom statuses, which
|
|
// belong to no group, and only appears when something is actually in it.
|
|
type ColumnKey = PipelineGroup | "Other";
|
|
|
|
// 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[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string").slice(0, 2) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
type PillTone = "error" | "warning" | "success" | "info";
|
|
|
|
// Mockup's single status pill per card ("Reply due in 2d", "Tomorrow 14:00", "720k NOK / yr").
|
|
// 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, 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" };
|
|
const days = Math.max(1, Math.ceil((due.getTime() - Date.now()) / 86400000));
|
|
return { text: t("kanbanReplyDueIn", { days }), tone: "warning" };
|
|
}
|
|
if (job.salary) return { text: job.salary, tone: "success" };
|
|
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({ refreshToken = 0, onResultCount }: { refreshToken?: number; onResultCount?: (count: number | null) => void }) {
|
|
const theme = useTheme();
|
|
const palette = theme.vars?.palette ?? theme.palette;
|
|
const { t, language } = useI18n();
|
|
const location = useLocation();
|
|
const query = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
|
|
const params = useDebouncedValue(query, 250);
|
|
const boardRef = useRef<HTMLDivElement>(null);
|
|
const navigate = useNavigate();
|
|
const [focusAfterMoveId, setFocusAfterMoveId] = useState<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("");
|
|
const [announcement, setAnnouncement] = useState("");
|
|
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", { params });
|
|
return response.data;
|
|
},
|
|
{
|
|
initialData: [],
|
|
errorMessage: t("kanbanLoadFailed"),
|
|
deps: [t, params, refreshToken],
|
|
},
|
|
);
|
|
|
|
const jobs = jobsResource.data;
|
|
useEffect(() => {
|
|
onResultCount?.(jobsResource.loading || jobsResource.refreshing || jobsResource.error ? null : jobs.length);
|
|
}, [onResultCount, jobs.length, jobsResource.loading, jobsResource.refreshing, jobsResource.error]);
|
|
|
|
useEffect(() => {
|
|
if (jobsResource.loading || jobsResource.refreshing || jobsResource.error) return;
|
|
const returnId = window.history.state?.jobBoardFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
|
|
const targetId = focusAfterMoveId ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
|
|
if (targetId == null) return;
|
|
const card = document.querySelector<HTMLElement>(`[data-kanban-card-id="${targetId}"] [role="button"]`);
|
|
// A successful move may leave the filtered population. Keep keyboard context in the board.
|
|
const target = card ?? (focusAfterMoveId != null ? boardRef.current?.querySelector<HTMLElement>('[role="group"]') : null);
|
|
if (!target) return;
|
|
target.focus();
|
|
setFocusAfterMoveId(null);
|
|
restoredFocusRef.current = targetId;
|
|
}, [jobs, jobsResource.loading, jobsResource.refreshing, jobsResource.error, location.state, focusAfterMoveId]);
|
|
|
|
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, []));
|
|
map.set("Other", []);
|
|
for (const j of jobs) {
|
|
map.get(groupOf(j.status) ?? "Other")!.push(j);
|
|
}
|
|
map.forEach((arr, k) => {
|
|
// Sort by stage first so a column reads in pipeline order (Saved before Preparing), then by
|
|
// recency. Fall back to savedAt: unapplied jobs have no dateApplied, and treating that as
|
|
// the epoch would sink them to the bottom.
|
|
arr.sort((a, b) => {
|
|
const stageDelta = PIPELINE_STATUSES.indexOf(normalizeStatus(a.status) as Status)
|
|
- PIPELINE_STATUSES.indexOf(normalizeStatus(b.status) as Status);
|
|
if (stageDelta !== 0) return stageDelta;
|
|
return +new Date(b.dateApplied ?? b.savedAt) - +new Date(a.dateApplied ?? a.savedAt);
|
|
});
|
|
map.set(k, arr);
|
|
});
|
|
return map;
|
|
}, [jobs]);
|
|
|
|
// A group is not a status, so dropping onto a column applies that group's entry stage. Precise
|
|
// stages stay on the card menu.
|
|
const onDropTo = async (group: PipelineGroup) => {
|
|
if (!dragJobId) return;
|
|
const movingJobId = dragJobId;
|
|
const status = GROUP_ENTRY_STATUS[group];
|
|
setDragJobId(null);
|
|
setDragOverColumn(null);
|
|
setMoveError("");
|
|
try {
|
|
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
|
|
await jobsResource.reload();
|
|
setFocusAfterMoveId(movingJobId);
|
|
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();
|
|
}
|
|
};
|
|
|
|
const setStatus = async (id: number, status: Status) => {
|
|
setMoveError("");
|
|
try {
|
|
await api.patch(`/jobapplications/${id}/status`, { status });
|
|
await jobsResource.reload();
|
|
setFocusAfterMoveId(id);
|
|
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();
|
|
}
|
|
};
|
|
|
|
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
|
|
|
// "Other" (custom statuses) only earns a column when something is in it, and is not a drop
|
|
// target — there is no single custom status to assign.
|
|
const columns = useMemo(() => {
|
|
const base: { key: ColumnKey; label: string; droppable: boolean }[] = PIPELINE_GROUPS.map((g) => ({
|
|
key: g.key,
|
|
label: t(g.labelKey as any),
|
|
droppable: true,
|
|
}));
|
|
if ((groups.get("Other") ?? []).length > 0) {
|
|
base.push({ key: "Other", label: t("kanbanGroupOther"), droppable: false });
|
|
}
|
|
return base;
|
|
}, [groups, t]);
|
|
|
|
return (
|
|
<Box ref={boardRef} sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>{t("kanbanHint")}</Typography>
|
|
|
|
<ViewStateNotice
|
|
loading={jobsResource.loading || jobsResource.refreshing}
|
|
error={jobsResource.error}
|
|
title={t("kanbanLoadFailed")}
|
|
description={t("kanbanLoadFailedBody")}
|
|
onRetry={jobsResource.reload}
|
|
/>
|
|
|
|
{!jobsResource.loading && !jobsResource.refreshing && !jobsResource.error ? (
|
|
<Box
|
|
sx={{
|
|
display: { xs: "flex", lg: "grid" },
|
|
width: "100%",
|
|
maxWidth: "100%",
|
|
minWidth: 0,
|
|
gridTemplateColumns: { lg: `repeat(${columns.length}, minmax(0, 1fr))` },
|
|
gap: 1.5,
|
|
alignItems: "start",
|
|
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) ?? [];
|
|
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 (
|
|
<Paper
|
|
key={key}
|
|
component="section"
|
|
role="group"
|
|
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"}
|
|
onFocus={() => { if (isDragActive) setDragOverColumn(key); }}
|
|
onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && isDragActive) {
|
|
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
|
|
} else if ((event.key === "Enter" || event.key === " ") && isDragActive) {
|
|
event.preventDefault();
|
|
if (droppable) void onDropTo(key as PipelineGroup);
|
|
else setAnnouncement(t("kanbanInvalidDropTarget", { status: label }));
|
|
}
|
|
}}
|
|
onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }}
|
|
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragOverColumn(null); }}
|
|
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
|
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
|
sx={{
|
|
p: 1.25,
|
|
borderRadius: 2.5,
|
|
minHeight: 220,
|
|
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,
|
|
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, gap: 1 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
|
<Typography component="h2" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>
|
|
{label}
|
|
</Typography>
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
minWidth: 22,
|
|
textAlign: "center",
|
|
px: 0.75,
|
|
py: 0.15,
|
|
borderRadius: 999,
|
|
backgroundColor: "action.hover",
|
|
}}
|
|
>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
|
{list.length}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
{list.map((j) => {
|
|
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 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
|
|
onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }}
|
|
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
|
|
sx={{
|
|
cursor: "grab",
|
|
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": { borderColor: "border.strong", backgroundColor: "action.hover" },
|
|
"&:active": { cursor: "grabbing" },
|
|
}}
|
|
>
|
|
<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>
|
|
<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,
|
|
borderColor: cardTone === "default" ? "divider" : cardColor,
|
|
color: cardTone === "default" ? "text.primary" : cardColor,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{tags.length > 0 && (
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
|
|
{tags.map((tag) => (
|
|
<Chip
|
|
key={tag}
|
|
size="small"
|
|
label={tag}
|
|
sx={{ height: 22, backgroundColor: "action.hover", color: "text.secondary", fontWeight: 600 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{pill && (
|
|
<Box sx={{ mt: 0.75 }}>
|
|
<Chip
|
|
size="small"
|
|
label={pill.text}
|
|
sx={{
|
|
height: 24,
|
|
fontWeight: 700,
|
|
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}
|
|
</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: 2, px: 0.5 }}>
|
|
{dragJobId !== null && droppable ? t("kanbanDropHere") : t("kanbanEmptyColumn")}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Box>
|
|
) : null}
|
|
|
|
{moveError ? <Alert severity="error" variant="outlined" sx={{ mt: 2, color: "text.primary" }} onClose={() => setMoveError("")}>{moveError}</Alert> : null}
|
|
<Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", p: 0, m: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: 0 }} aria-live="polite">
|
|
{announcement}
|
|
</Box>
|
|
|
|
{/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */}
|
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
|
{PIPELINE_GROUPS.flatMap((g) => {
|
|
const options = g.statuses.filter((s) => s !== currentMenuStatus);
|
|
if (options.length === 0) return [];
|
|
return [
|
|
<ListSubheader key={`${g.key}-header`} sx={{ lineHeight: "32px", backgroundColor: "transparent" }}>
|
|
{t(g.labelKey as any)}
|
|
</ListSubheader>,
|
|
...options.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>
|
|
);
|
|
}
|