"use client"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react"; import { toast } from "sonner"; import type { Stage } from "@prisma/client"; import { STAGES, STAGE_LABEL, STAGE_TONE, TERMINAL_STAGES } from "@/lib/stages"; import { STALE_AFTER, hasGoneQuiet } from "@/lib/quiet"; import type { ListRow, ListSort } from "@/lib/pipeline-list"; import { ApplicationActions } from "@/components/pipeline/application-actions"; import { CompanyAvatar } from "@/components/pipeline/company-avatar"; import { useOpenApplication } from "@/components/pipeline/application-panel"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { parseISODate, toISODate } from "@/components/ui/date-field"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ColumnGrip, ResizableColumns, useColumnStyle, } from "@/components/lists/resizable-columns"; import { SortMenu } from "@/components/lists/sort-menu"; import type { StoredWidths } from "@/lib/column-widths"; import { moveApplicationsStageAction, moveStageAction, updateApplicationAction } from "@/server/actions"; import { cn } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; import { civilDay, civilInstant, shortCivilDay, shortDay } from "@/lib/time"; /** * The table view, which is also the fastest way to edit. * * The row used to be a single link, which made it a good list and a bad table: * every correction — a stage, a salary you finally learned, a follow-up you * want a week later — meant opening a panel and closing it again. Now the * company cell navigates and the rest of the cells are the fields themselves, * so a table of twenty applications can be brought up to date without leaving * it. Edits save on change (stage) or on blur (text and dates), matching the * rest of the app, which has no save button anywhere. */ /** * The columns, and which of them can be turned off. * * `field: null` means structural. Company is what a row IS; Stage is the inline * editor the table exists for (it writes straight through to the board, see * the header comment above); the checkbox and the actions cell are controls, * not data. A setting that can make a screen useless is not a setting. */ /** * `col` is the key in the width catalogue. A column without one cannot be * dragged: the company cell flexes to fill whatever the rest leave, and the * trailing actions cell is a control. The Tailwind `w-*` on a resizable column * is the width before anything is stored — an inline width from the catalogue * overrides it, and the class is what renders where there is no provider, on * the shared pipeline and the print page. */ const COLUMNS: { key: ListSort | null; label: string; className: string; field: string | null; col?: string; }[] = [ { key: "company", label: "Company", className: "flex-1 min-w-0", field: null }, { key: "stage", label: "Stage", className: "w-32 shrink-0", field: null, col: "stage" }, { key: "followUp", label: "Follow-up", className: "w-28 shrink-0", field: "followUp", col: "followUp", }, { key: "waiting", label: "Waiting", className: "w-20 shrink-0 text-right", field: "waiting", col: "waiting", }, { key: "quiet", label: "Quiet", className: "w-20 shrink-0 text-right hidden md:block", field: "quiet", col: "quiet", }, { key: "salary", label: "Salary", className: "w-32 shrink-0 hidden lg:block", field: "salary", col: "salary", }, { key: null, label: "Location", className: "w-32 shrink-0 hidden xl:block", field: "location", col: "location", }, { key: null, label: "Log", className: "w-10 shrink-0 text-right hidden sm:block", field: "activity", col: "activity", }, { key: "updated", label: "Touched", className: "w-20 shrink-0 text-right hidden sm:block", field: "updated", col: "updated", }, { key: null, label: "", className: "w-8 shrink-0", field: null }, ]; /** * A body cell at the dragged width. * * `className` keeps the Tailwind `w-*` it replaces, so the cell still has a * width where there is no provider — the shared pipeline and the print page * render these rows with nothing to drag. */ function Body({ col, className, title, children, }: { col: string; className?: string; title?: string; children: React.ReactNode; }) { const style = useColumnStyle(col); return (
{children}
); } /** One header or body cell, at whatever width the catalogue says. */ function Cell({ column, children, className, }: { column: (typeof COLUMNS)[number]; children?: React.ReactNode; className?: string; }) { const style = useColumnStyle(column.col ?? ""); return (
{children}
); } export function PipelineList({ rows, sort, desc, fields, widths, narrowed, }: { rows: ListRow[]; sort: ListSort; desc: boolean; /** Which optional columns to draw. Structural ones are always drawn. */ fields: string[]; /** Stored column widths, already parsed and clamped by the server. */ widths: StoredWidths; /** Whether a filter or a search is narrowing this, which changes what an * empty table means — and the page above is the only thing that knows. */ narrowed: boolean; }) { const [selected, setSelected] = useState>(new Set()); const params = useSearchParams(); const visibleIds = useMemo(() => rows.map((row) => row.id), [rows]); // Structural columns are never in the catalogue, so they are never dropped. const columns = useMemo(() => { const on = new Set(fields); return COLUMNS.filter((column) => column.field === null || on.has(column.field)); }, [fields]); const chosen = useMemo( () => visibleIds.filter((id) => selected.has(id)), [visibleIds, selected], ); const toggle = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const toggleAll = () => setSelected((prev) => chosen.length === visibleIds.length ? new Set() : new Set([...prev, ...visibleIds]), ); if (rows.length === 0) { // "Nothing tracked yet" was hard-coded here, and on a filtered table it was // simply untrue: twenty-five applications on file, a search that matched // none of them, and a message saying the workspace was empty. The page // short-circuits the genuinely-empty case before this ever renders, so the // only question left is whether the person can see the way out. return (
{narrowed ? "Nothing matches. Clear the search or the filters to see everything again." : "Nothing tracked yet."}
); } return (
{chosen.length > 0 && ( setSelected(new Set())} /> )} {/* Sorting, reachable on a phone. Half these keys have no heading to click below `md`, and the toolbar above is shared with the board and the calendar, where a sort key means nothing — so it lives here, with the table it sorts. */}
({ key, label, href: sortHref(key, sort, desc, params), ascHref: dirHref(key, false, params), descHref: dirHref(key, true, params), }))} />
{columns.map((column) => ( {column.col && } {column.key ? ( {column.label} {sort === column.key && (desc ? ( ) : ( ))} ) : ( column.label )} ))}
    {rows.map((row) => ( toggle(row.id)} /> ))}
); } function Row({ row, columns, selected, onSelect, }: { row: ListRow; columns: typeof COLUMNS; selected: boolean; onSelect: () => void; }) { // One lookup rather than a Set per cell: a row draws ten cells and there are // as many rows as the person has applications. const shows = (field: string) => columns.some((column) => column.field === field); const zone = useViewerZone(); const openPanel = useOpenApplication(); const router = useRouter(); const [, startTransition] = useTransition(); // Optimistic local copy: an edit shows immediately and rolls back if the // server refuses, rather than the row sitting unchanged until a refresh. const [values, setValues] = useState({ stage: row.stage, nextFollowUpAt: row.nextFollowUpAt ? civilDay(new Date(row.nextFollowUpAt), zone) : "", salaryRange: row.salaryRange, location: row.location, }); // 9am on that day where the reader is — the hour the app sets a follow-up // for, so a row goes red at the same moment the board card and the bell do. // Reading the bare date as `new Date` made it UTC midnight, which is 5pm the // day before in Los Angeles: everything due today showed as overdue. const due = values.nextFollowUpAt ? civilInstant(zone, values.nextFollowUpAt, 9) : null; const overdue = due ? due.getTime() < Date.now() : false; const closed = TERMINAL_STAGES.includes(values.stage); // Null where the quiet rule has no threshold — a wishlist entry has not gone // quiet because nothing was meant to happen yet, and a closed one is over. const quiet = STALE_AFTER[values.stage] === undefined ? null : row.quietDays; const save = (patch: Partial, run: () => Promise) => { const before = values; setValues((current) => ({ ...current, ...patch })); startTransition(async () => { try { await run(); router.refresh(); } catch (error) { setValues(before); toast.error(error instanceof Error ? error.message : "Could not save that."); } }); }; const commitText = (key: "salaryRange" | "location", value: string) => { if (value === row[key]) return; save({ [key]: value }, () => updateApplicationAction(row.id, { [key]: value })); }; return (
  • {/* The one cell that still navigates. */} { if (!openPanel || event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); openPanel(row.id); }} className="hover:text-primary flex min-w-0 flex-1 items-center gap-2.5 transition-colors duration-150" >
    {row.company}
    {row.roleTitle}
    {shows("followUp") && ( save({ nextFollowUpAt: value }, () => updateApplicationAction(row.id, { nextFollowUpAt: value || null }), ) } /> )} {/* Read-only: this is a measurement, not a field. Dashes on a closed application because "waiting 40 days" is meaningless once it is over. */} {shows("waiting") && ( = 21 ? "text-destructive" : "text-faint", )} title={closed ? undefined : `In ${STAGE_LABEL[values.stage]} for ${row.daysInStage} days`} > {closed ? "—" : `${row.daysInStage}d`} )} {/* The other measurement, and the one chasing is decided on: days since anything at all happened, not days since it last moved. */} {shows("quiet") && ( {quiet === null ? "—" : `${quiet}d`} )} {shows("salary") && ( commitText("salaryRange", value)} onLocalChange={(value) => setValues((c) => ({ ...c, salaryRange: value }))} className="nums" /> )} {shows("location") && ( commitText("location", value)} onLocalChange={(value) => setValues((c) => ({ ...c, location: value }))} /> )} {shows("activity") && ( {row.activityCount || "—"} )} {shows("updated") && ( {shortDay(new Date(row.updatedAt), zone)} )} {/* The same verbs the board card offers. Two views of one pipeline should not disagree about what you can do to a row. */}
  • ); } /** * A date cell that is a dash until you use it. * * An empty `input[type=date]` prints "mm/dd/yyyy", and a column of those on * every row without a follow-up is louder than the dates that are actually * set — the opposite of what the column is for. So an empty cell renders as * the same em dash the rest of the table uses and only becomes a picker once * you click it. */ function DateCell({ value, label, overdue, onChange, }: { value: string; label: string; overdue: boolean; onChange: (value: string) => void; }) { const [open, setOpen] = useState(false); const selected = parseISODate(value); return ( { onChange(next ? toISODate(next) : ""); setOpen(false); }} /> {/* The one act the grid cannot express. Clearing by clicking the selected day again is real but undiscoverable, so it is also a row under the calendar. */} {selected && ( )} ); } /** * A cell that looks like text until you touch it. A table of visible input * boxes reads as a form and destroys the density the list exists for, so the * border and background only arrive on hover and focus. */ function CellInput({ value, placeholder, className, onBlurValue, onLocalChange, ...props }: { value: string; placeholder?: string; className?: string; autoFocus?: boolean; /** Commit on blur — right for free text, which is not done until you leave. */ onBlurValue?: (value: string) => void; onLocalChange?: (value: string) => void; "aria-label": string; }) { return ( onLocalChange?.(event.target.value)} onBlur={(event) => onBlurValue?.(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }} className={cn( "text-muted-foreground placeholder:text-faint h-7 w-full min-w-0 truncate rounded-control border border-transparent bg-transparent px-1.5 text-base transition-colors duration-150 outline-none md:text-[12px]", "hover:border-input hover:bg-inset focus:border-ring focus:bg-inset focus:ring-ring/25 focus:ring-2", className, )} /> ); } /** Appears only when something is selected: a toolbar you did not ask for is clutter. */ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) { const router = useRouter(); const [pending, startTransition] = useTransition(); const move = (stage: Stage) => startTransition(async () => { try { const result = await moveApplicationsStageAction(ids, stage); const n = result.moved.length; toast.success( `${n} ${n === 1 ? "application" : "applications"} → ${STAGE_LABEL[stage]}` + (result.skipped.length ? `, ${result.skipped.length} skipped` : ""), ); onDone(); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not move those."); } }); return (
    {ids.length} selected Move to
    ); } /** * Clicking the column you're already sorted by flips the direction. * * Built from the URL you are on rather than from scratch: the old version * made a fresh query string, so sorting a filtered list silently threw away * the filters, the search and the saved view you were looking through. */ /** The sort keys, for the menu. The headings carry the same set. */ const SORTS: { key: ListSort; label: string }[] = [ { key: "company", label: "Company" }, { key: "stage", label: "Stage" }, { key: "followUp", label: "Follow-up" }, { key: "waiting", label: "Waiting" }, { key: "quiet", label: "Quiet" }, { key: "salary", label: "Salary" }, { key: "updated", label: "Touched" }, ]; /** A direction picked outright, rather than the heading's toggle. */ function dirHref(key: ListSort, desc: boolean, current: URLSearchParams) { const params = new URLSearchParams(current); params.set("view", "list"); params.set("sort", key); if (desc) params.set("dir", "desc"); else params.delete("dir"); return `/applications?${params}`; } function sortHref(key: ListSort, sort: ListSort, desc: boolean, current: URLSearchParams) { const params = new URLSearchParams(current); params.set("view", "list"); params.set("sort", key); if (key === sort && !desc) params.set("dir", "desc"); else params.delete("dir"); return `/applications?${params}`; }