import React, { useMemo, 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 { api, getApiErrorMessage } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; 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"; const TONE_PALETTE: Record 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[] { 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): { 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: new Date(job.deadline).toLocaleDateString(), 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 [dragJobId, setDragJobId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [moveError, setMoveError] = useState(""); const [announcement, setAnnouncement] = useState(""); const [menuAnchor, setMenuAnchor] = useState(null); const [menuJobId, setMenuJobId] = useState(null); const jobsResource = useViewResource( async () => { const response = await api.get("/jobapplications/board"); return response.data; }, { initialData: [], errorMessage: t("kanbanLoadFailed"), deps: [t], }, ); const jobs = jobsResource.data; const groups = useMemo(() => { const map = new Map(); 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 }); 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")); } }; const setStatus = async (id: number, status: Status) => { setMoveError(""); try { await api.patch(`/jobapplications/${id}/status`, { status }); 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")); } }; 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 ( {t("kanbanHint")} {!jobsResource.loading && !jobsResource.error ? ( {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 isDragActive = dragJobId !== null; const isActiveTarget = dragOverColumn === key; return ( { 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.5, borderRadius: 3, 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", 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 }, }} > {label} {list.length} {list.map((j) => { const pill = cardPill(j, t); 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); return ( { 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", outlineOffset: 2, "&:hover": { boxShadow: 4, transform: "translateY(-1px)" }, "&:focus-visible": { boxShadow: customShadows?.focus }, "&:active": { cursor: "grabbing" }, }} > {j.jobTitle} { e.stopPropagation(); setMenuJobId(j.id); setMenuAnchor(e.currentTarget); }} > {[j.company?.name, j.location].filter(Boolean).join(" · ")} {tags.length > 0 && ( {tags.map((tag) => ( ))} )} {pill && ( )} {j.daysSince != null ? ( {t("kanbanAppliedAgo", { days: j.daysSince })} ) : null} ); })} {list.length === 0 && ( {t("kanbanDropHere")} )} ); })} ) : null} {moveError ? setMoveError("")}>{moveError} : null} {announcement} {/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */} { setMenuAnchor(null); setMenuJobId(null); }}> {PIPELINE_GROUPS.flatMap((g) => { const options = g.statuses.filter((s) => s !== currentMenuStatus); if (options.length === 0) return []; return [ {t(g.labelKey as any)} , ...options.map((s) => ( { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}> {t("jobTableSetStatus", { status: statusLabel(t, s) })} )), ]; })} ); }