"use client"; import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Building2Icon, CircleUserRoundIcon, KanbanIcon, RotateCcwIcon, Trash2Icon, XIcon, } from "lucide-react"; import { toast } from "sonner"; import { EmptyState } from "@/components/page-header"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { PurgeDialog } from "@/components/archive/purge-dialog"; import { deleteArchivedAction, restoreRecordsAction } from "@/server/actions"; import { cn } from "@/lib/utils"; export type BinRow = { kind: "company" | "contact" | "application"; id: string; title: string; subtitle: string; withIt: string; archivedAgo: string; /** "in 23 days", "today", or "never" when retention is off. */ goesIn: string; /** Days left, for colouring. Null when nothing is scheduled. */ daysLeft: number | null; nameTaken: boolean; }; const ICON = { company: Building2Icon, contact: CircleUserRoundIcon, application: KanbanIcon, }; const KIND_LABEL = { company: "Company", contact: "Person", application: "Application" }; /** * The bin. * * Restoring acts immediately โ€” that is not the dangerous direction. Deleting * for good goes through a dialog that states what will go, computed from the * selection rather than boilerplate, because "delete 4 items" and "delete * Stripe and the 3 applications archived with it" are different sentences and * only one of them is true. */ export function ArchiveList({ rows, filtered }: { rows: BinRow[]; filtered: boolean }) { const router = useRouter(); const [selected, setSelected] = useState>(new Set()); const [purging, setPurging] = useState(false); const [pending, startTransition] = useTransition(); const key = (row: BinRow) => `${row.kind}:${row.id}`; const chosen = useMemo(() => rows.filter((row) => selected.has(key(row))), [rows, selected]); const allOn = rows.length > 0 && rows.every((row) => selected.has(key(row))); const toggle = (row: BinRow) => setSelected((prev) => { const next = new Set(prev); if (next.has(key(row))) next.delete(key(row)); else next.add(key(row)); return next; }); const toggleAll = () => setSelected((prev) => { const next = new Set(prev); for (const row of rows) { if (allOn) next.delete(key(row)); else next.add(key(row)); } return next; }); /** Mixed selections are grouped, so it is usually one call per kind. */ const byKind = (list: BinRow[]) => { const groups = new Map(); for (const row of list) groups.set(row.kind, [...(groups.get(row.kind) ?? []), row.id]); return [...groups.entries()]; }; const restore = () => startTransition(async () => { try { let back = 0; const refused: string[] = []; for (const [kind, ids] of byKind(chosen)) { const result = await restoreRecordsAction(kind, ids); back += result.restored.length + result.alsoRestored.length; refused.push(...result.skipped.map((skip) => skip.reason)); } if (back > 0) toast.success(`${back} restored`); // Refusals are the only outcome worth a message when nothing came // back โ€” a silent Restore reads as a broken button. if (back === 0 && refused.length === 0) toast.error("Nothing was restored."); for (const reason of refused.slice(0, 2)) toast.error(reason); setSelected(new Set()); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not restore those."); } }); const purge = () => startTransition(async () => { try { let gone = 0; for (const [kind, ids] of byKind(chosen)) { gone += (await deleteArchivedAction(kind, ids)).deleted.length; } toast.success(`${gone} deleted for good`); setPurging(false); setSelected(new Set()); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not delete those."); } }); if (rows.length === 0) { return ( ); } return (
{/* The kind chips and the search can take ticked rows off screen. Say what is about to happen to how many, rather than counting rows the buttons will not touch. */} {selected.size > chosen.length && (

{selected.size - chosen.length} more selected {selected.size - chosen.length === 1 ? "is" : "are"} not on this screen, and will be left alone.

)} {chosen.length > 0 && (
{chosen.length} selected
)}
What it was
Kind
Archived
Deleted
    {rows.map((row) => { const Icon = ICON[row.kind]; return (
  • toggle(row)} aria-label={`Select ${row.title}`} />
    {row.title}
    {[row.subtitle, row.withIt].filter(Boolean).join(" ยท ") || "โ€”"}
    {row.nameTaken && (
    A live company has taken this name. Rename that one first, or restore this and merge them.
    )}
    {KIND_LABEL[row.kind]}
    {row.archivedAgo}
    {row.goesIn}
  • ); })}
); }