import type { ApplicationStatus, ApplicationTimelineEntry } from "@reactive-resume/schema/applications/data"; import type { Application } from "../types"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ArchiveIcon, ArrowRightIcon, TagIcon, TrashIcon } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { STAGES } from "@reactive-resume/schema/applications/data"; import { Badge } from "@reactive-resume/ui/components/badge"; import { Button } from "@reactive-resume/ui/components/button"; import { Checkbox } from "@reactive-resume/ui/components/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@reactive-resume/ui/components/dropdown-menu"; import { Input } from "@reactive-resume/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover"; import { toast } from "@reactive-resume/ui/components/toast"; import { getInitials } from "@reactive-resume/utils/string"; import { cn } from "@reactive-resume/utils/style"; import { orpc } from "@/libs/orpc/client"; import { applicationsListQueryKey } from "../queries"; import { tileColor } from "../tile-color"; import { ApplicationActionsMenu } from "./application-actions-menu"; const PAGE_SIZE = 25; const stageOf = (status: string) => STAGES.find((s) => s.value === status); const byNewest = (a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) => new Date(b.at).getTime() - new Date(a.at).getTime(); const latestStageDate = (activity: ApplicationTimelineEntry[], status: ApplicationStatus) => [...activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === status)?.at; const formatDate = (value: Date | string) => new Date(value).toLocaleDateString(undefined, { month: "numeric", day: "numeric", timeZone: "UTC", year: "numeric" }); type Props = { applications: Application[]; onOpen: (application: Application) => void; onEdit: (application: Application) => void; }; export function ApplicationTable({ applications, onOpen, onEdit }: Props) { const queryClient = useQueryClient(); const [selected, setSelected] = useState>(new Set()); const [page, setPage] = useState(0); // Drop selected rows that are no longer in the current (filtered) set, so the bulk-action bar // never reports a count for rows the user can't see. useEffect(() => { setSelected((prev) => { if (prev.size === 0) return prev; const visible = new Set(applications.map((app) => app.id)); const next = new Set([...prev].filter((id) => visible.has(id))); return next.size === prev.size ? prev : next; }); }, [applications]); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() }); void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() }); void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() }); }; const clearSelection = () => setSelected(new Set()); const bulkUpdate = useMutation( orpc.applications.bulkUpdate.mutationOptions({ onSuccess: () => { invalidate(); clearSelection(); }, onError: () => toast.add({ type: "error", description: t`Bulk update failed. Please try again.` }), }), ); const bulkDelete = useMutation( orpc.applications.bulkDelete.mutationOptions({ onSuccess: (result) => { invalidate(); clearSelection(); toast.add({ type: "success", description: t`Deleted ${result.deleted} application(s).` }); }, onError: () => toast.add({ type: "error", description: t`Bulk delete failed. Please try again.` }), }), ); const pageCount = Math.max(1, Math.ceil(applications.length / PAGE_SIZE)); const safePage = Math.min(page, pageCount - 1); const rows = useMemo( () => applications.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE), [applications, safePage], ); const pageIds = rows.map((row) => row.id); const allChecked = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); const someChecked = pageIds.some((id) => selected.has(id)); const toggleAll = () => { setSelected((prev) => { const next = new Set(prev); if (allChecked) for (const id of pageIds) next.delete(id); else for (const id of pageIds) next.add(id); return next; }); }; const toggleOne = (id: string) => { setSelected((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const ids = [...selected]; return (
{selected.size > 0 && (
{selected.size} selected Move stage } /> {STAGES.map((stage) => ( bulkUpdate.mutate({ ids, status: stage.value })}> {stage.label} ))} bulkUpdate.mutate({ ids, addTags: [tag] })} />
)} {/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */}
{rows.map((app) => { const stage = stageOf(app.status); return ( ); })}
Company / Role Stage Location Salary Tags Source Applied Actions
toggleOne(app.id)} aria-label={t`Select ${app.company}`} /> {stage?.label ?? app.status} {app.location || "—"} {app.salary || "—"}
{app.tags.slice(0, 2).map((tag) => ( {tag} ))} {app.tags.length > 2 && ( +{app.tags.length - 2} )}
{app.source || "—"} {formatDate(latestStageDate(app.activity, "applied") ?? app.appliedAt)}
{/* Mobile: stacked cards (same paginated rows), tap to open. */}
{rows.map((app) => { const stage = stageOf(app.status); return (
toggleOne(app.id)} aria-label={t`Select ${app.company}`} />
); })}
Showing {rows.length} of {applications.length} {pageCount > 1 && (
{safePage + 1} / {pageCount}
)}
); } function AddTagPopover({ onAdd }: { onAdd: (tag: string) => void }) { const [value, setValue] = useState(""); const [open, setOpen] = useState(false); const submit = () => { const tag = value.trim(); if (!tag) return; onAdd(tag); setValue(""); setOpen(false); }; return ( Add tag } />
setValue(event.target.value)} onKeyDown={(event) => event.key === "Enter" && submit()} />
); }