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(null); const navigate = useNavigate(); const [focusAfterMoveId, setFocusAfterMoveId] = useState(null); const restoredFocusRef = useRef(null); 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", { 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(`[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('[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(); 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(`[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(`[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 ( {t("kanbanHint")} {!jobsResource.loading && !jobsResource.refreshing && !jobsResource.error ? ( {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 ( { 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 }, }} > {label} {list.length} {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 ( { 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" }, }} > { 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 } }} > {j.jobTitle} {[j.company?.name, j.location].filter(Boolean).join(" · ")} {tags.length > 0 && ( {tags.map((tag) => ( ))} )} {pill && ( )} {j.daysSince != null ? ( {t("kanbanAppliedAgo", { days: j.daysSince })} ) : null} { setMenuJobId(j.id); setMenuAnchor(e.currentTarget); }} sx={{ position: "absolute", top: 5, right: 5, minWidth: { xs: 44, sm: 36 }, minHeight: { xs: 44, sm: 36 } }} > ); })} {list.length === 0 && ( {dragJobId !== null && droppable ? t("kanbanDropHere") : t("kanbanEmptyColumn")} )} ); })} ) : 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) })} )), ]; })} ); }