"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. */}
);
}
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}`;
}