src/components/pipeline/list.tsx
Rama main · réplica de lectura del repositorio original.
"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 (
<div className={className} style={style} title={title}>
{children}
</div>
);
}
/** 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 (
<div className={cn(column.className, className)} style={style}>
{children}
</div>
);
}
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<Set<string>>(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 (
<div className="text-faint rounded-xl border border-dashed py-16 text-center text-[13px]">
{narrowed
? "Nothing matches. Clear the search or the filters to see everything again."
: "Nothing tracked yet."}
</div>
);
}
return (
<ResizableColumns list="pipeline" stored={widths}>
<div className="space-y-2">
{chosen.length > 0 && (
<BulkBar ids={chosen} onDone={() => 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. */}
<div className="flex justify-end">
<SortMenu
active={sort}
desc={desc}
ariaLabel="Sort the pipeline table"
options={SORTS.map(({ key, label }) => ({
key,
label,
href: sortHref(key, sort, desc, params),
ascHref: dirHref(key, false, params),
descHref: dirHref(key, true, params),
}))}
/>
</div>
<div className="bg-card shadow-card overflow-hidden rounded-xl">
<div className="eyebrow bg-inset flex items-center gap-3 px-4 py-2">
<div className="flex w-5 shrink-0 items-center">
<Checkbox
checked={chosen.length === visibleIds.length}
onCheckedChange={toggleAll}
aria-label="Select every row"
/>
</div>
{columns.map((column) => (
<Cell key={column.label} column={column} className="relative">
{column.col && <ColumnGrip column={column.col} />}
{column.key ? (
<Link
href={sortHref(column.key, sort, desc, params)}
className="hover:text-foreground touch-target inline-flex min-h-11 items-center gap-1 transition-colors duration-150 md:min-h-0"
>
{column.label}
{sort === column.key &&
(desc ? (
<ArrowDownIcon className="size-2.5" />
) : (
<ArrowUpIcon className="size-2.5" />
))}
</Link>
) : (
column.label
)}
</Cell>
))}
</div>
<ul className="divide-y">
{rows.map((row) => (
<Row
key={row.id}
row={row}
columns={columns}
selected={selected.has(row.id)}
onSelect={() => toggle(row.id)}
/>
))}
</ul>
</div>
</div>
</ResizableColumns>
);
}
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<typeof values>, run: () => Promise<unknown>) => {
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 (
<li
style={{ ["--tone" as string]: STAGE_TONE[values.stage] }}
className={cn("stage-band flex items-center gap-3 px-4 py-2", selected && "bg-accent/40")}
>
<div className="flex w-5 shrink-0 items-center">
<Checkbox checked={selected} onCheckedChange={onSelect} aria-label={`Select ${row.company}`} />
</div>
{/* The one cell that still navigates. */}
<Link
href={`/applications/${row.id}`}
data-nav-item
onClick={(event) => {
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"
>
<CompanyAvatar name={row.company} domain={row.domain} size={26} />
<div className="min-w-0">
<span className="block truncate text-[13px] font-medium">{row.company}</span>
<div className="text-faint truncate text-[12px]">{row.roleTitle}</div>
</div>
</Link>
<Body col="stage" className="w-32 shrink-0">
<Select
value={values.stage}
onValueChange={(value) =>
save({ stage: value as Stage }, () => moveStageAction(row.id, value as Stage))
}
>
<SelectTrigger
size="sm"
aria-label={`Stage for ${row.company}`}
className="stage-chip h-7 w-full border-0 px-1.5 text-[11.5px] font-medium shadow-none md:h-7"
style={{ ["--tone" as string]: STAGE_TONE[values.stage] }}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STAGES.map((stage) => (
<SelectItem key={stage} value={stage}>
{STAGE_LABEL[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
</Body>
{shows("followUp") && (
<Body col="followUp" className="w-28 shrink-0">
<DateCell
value={values.nextFollowUpAt}
label={`Follow-up date for ${row.company}`}
overdue={overdue}
onChange={(value) =>
save({ nextFollowUpAt: value }, () =>
updateApplicationAction(row.id, { nextFollowUpAt: value || null }),
)
}
/>
</Body>
)}
{/* 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") && (
<Body
col="waiting"
className={cn(
"nums w-20 shrink-0 text-right text-[12px]",
!closed && row.daysInStage >= 21 ? "text-destructive" : "text-faint",
)}
title={closed ? undefined : `In ${STAGE_LABEL[values.stage]} for ${row.daysInStage} days`}
>
{closed ? "—" : `${row.daysInStage}d`}
</Body>
)}
{/* The other measurement, and the one chasing is decided on: days since
anything at all happened, not days since it last moved. */}
{shows("quiet") && (
<Body
col="quiet"
className={cn(
"nums hidden w-20 shrink-0 text-right text-[12px] md:block",
hasGoneQuiet(values.stage, row.quietDays, TERMINAL_STAGES)
? "text-[var(--warning)] font-medium"
: "text-faint",
)}
title={quiet === null ? undefined : `Nothing logged for ${row.quietDays} days`}
>
{quiet === null ? "—" : `${quiet}d`}
</Body>
)}
{shows("salary") && (
<Body col="salary" className="hidden w-32 shrink-0 lg:block">
<CellInput
value={values.salaryRange}
placeholder="—"
aria-label={`Salary for ${row.company}`}
onBlurValue={(value) => commitText("salaryRange", value)}
onLocalChange={(value) => setValues((c) => ({ ...c, salaryRange: value }))}
className="nums"
/>
</Body>
)}
{shows("location") && (
<Body col="location" className="hidden w-32 shrink-0 xl:block">
<CellInput
value={values.location}
placeholder="—"
aria-label={`Location for ${row.company}`}
onBlurValue={(value) => commitText("location", value)}
onLocalChange={(value) => setValues((c) => ({ ...c, location: value }))}
/>
</Body>
)}
{shows("activity") && (
<Body col="activity" className="nums text-faint hidden w-10 shrink-0 text-right text-[12px] sm:block">
{row.activityCount || "—"}
</Body>
)}
{shows("updated") && (
<Body col="updated" className="nums text-faint hidden w-20 shrink-0 text-right text-[12px] sm:block">
{shortDay(new Date(row.updatedAt), zone)}
</Body>
)}
{/* The same verbs the board card offers. Two views of one pipeline
should not disagree about what you can do to a row. */}
<div className="flex w-8 shrink-0 justify-end">
<ApplicationActions
application={{
id: row.id,
company: row.company,
roleTitle: row.roleTitle,
stage: values.stage,
jobUrl: row.jobUrl,
}}
/>
</div>
</li>
);
}
/**
* 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className={cn(
"nums hover:border-input hover:bg-inset h-7 w-full rounded-control border border-transparent px-1.5 text-left text-[12px] transition-colors duration-150",
!selected && "text-faint",
overdue && selected && "text-destructive font-medium",
)}
>
{value ? shortCivilDay(value) : "—"}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<Calendar
mode="single"
selected={selected}
defaultMonth={selected}
captionLayout="dropdown"
autoFocus
onSelect={(next) => {
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 && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive mt-1 w-full"
onClick={() => {
onChange("");
setOpen(false);
}}
>
<XIcon /> Clear
</Button>
)}
</PopoverContent>
</Popover>
);
}
/**
* 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 (
<input
{...props}
value={value}
placeholder={placeholder}
onChange={(event) => 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 (
<div className="bg-card shadow-card flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="nums text-[12.5px] font-medium">
{ids.length} selected
</span>
<span className="text-faint hidden text-[12px] sm:inline">Move to</span>
<Select onValueChange={(value) => move(value as Stage)} disabled={pending}>
<SelectTrigger size="sm" className="w-40" aria-label="Move selected to stage">
<SelectValue placeholder="Pick a stage" />
</SelectTrigger>
<SelectContent>
{STAGES.map((stage) => (
<SelectItem key={stage} value={stage}>
{STAGE_LABEL[stage]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground ml-auto"
onClick={onDone}
disabled={pending}
>
<XIcon /> Clear
</Button>
</div>
);
}
/**
* 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}`;
}