| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useSearchParams } from "next/navigation"; |
| 5 | import { Card, SectionHeader } from "@/components/ui"; |
| 6 | import { DataTable, type Column, type Row } from "@/components/data-table"; |
| 7 | import { SourceLink } from "@/components/source-link"; |
| 8 | import { formatUsd, formatDate } from "@/lib/format"; |
| 9 | import { |
| 10 | SearchFilterBar, |
| 11 | FilterField, |
| 12 | FilterInput, |
| 13 | FilterSelect, |
| 14 | FilterSubmit, |
| 15 | } from "@/components/search-filter-bar"; |
| 16 | import type { Award } from "@/lib/feed"; |
| 17 | |
| 18 | function AwardsSkeleton() { |
| 19 | return ( |
| 20 | <div className="mt-8 space-y-4"> |
| 21 | <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" /> |
| 22 | <div className="mt-8 mb-4 h-4 w-48 animate-pulse rounded-sm bg-graphite" /> |
| 23 | <div className="space-y-1"> |
| 24 | {Array.from({ length: 10 }).map((_, i) => ( |
| 25 | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> |
| 26 | ))} |
| 27 | </div> |
| 28 | </div> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | function AwardsTable({ awards }: { awards: Award[] }) { |
| 33 | const hasDate = awards.some((a) => a.actionDate && a.actionDate.trim()); |
| 34 | const columns: Column[] = [ |
| 35 | { key: "agency", header: "Agency", cardTitle: true }, |
| 36 | { key: "recipient", header: "Recipient" }, |
| 37 | { key: "program", header: "Program", hideOnCard: true }, |
| 38 | ...(hasDate ? [{ key: "date", header: "Date" } as Column] : []), |
| 39 | { key: "amount", header: "Amount", align: "right" as const }, |
| 40 | { key: "source", header: "Source", align: "right" as const }, |
| 41 | ]; |
| 42 | const rows: Row[] = awards.map((a) => ({ |
| 43 | key: a.id, |
| 44 | cells: { |
| 45 | agency: <span className="text-paper">{a.agency}</span>, |
| 46 | recipient: <span className="text-ash">{a.recipient}</span>, |
| 47 | program: <span className="text-ash">{a.program}</span>, |
| 48 | ...(hasDate |
| 49 | ? { |
| 50 | date: ( |
| 51 | <span className="tabular-nums text-ash-2"> |
| 52 | {formatDate(a.actionDate)} |
| 53 | </span> |
| 54 | ), |
| 55 | } |
| 56 | : {}), |
| 57 | amount: <span className="tabular-nums text-paper">{a.amountText}</span>, |
| 58 | source: <SourceLink url={a.sourceUrl} />, |
| 59 | }, |
| 60 | })); |
| 61 | return ( |
| 62 | <DataTable |
| 63 | caption="Federal awards: agency, recipient, program, amount, source." |
| 64 | cardLabel="Federal awards" |
| 65 | columns={columns} |
| 66 | rows={rows} |
| 67 | pageSize={50} |
| 68 | /> |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | export function SpendingAwardsBrowser() { |
| 73 | const sp = useSearchParams(); |
| 74 | const q = sp.get("q") ?? ""; |
| 75 | const sortParam = sp.get("sort") ?? ""; |
| 76 | |
| 77 | const [awards, setAwards] = useState<Award[]>([]); |
| 78 | const [loading, setLoading] = useState(true); |
| 79 | |
| 80 | useEffect(() => { |
| 81 | setLoading(true); |
| 82 | const params = new URLSearchParams(); |
| 83 | if (q) params.set("q", q); |
| 84 | if (sortParam) params.set("sort", sortParam); |
| 85 | params.set("limit", "100"); |
| 86 | |
| 87 | fetch(`/api/spending/awards?${params.toString()}`) |
| 88 | .then((r) => r.json()) |
| 89 | .then((data) => { |
| 90 | setAwards(data.items ?? []); |
| 91 | }) |
| 92 | .catch(() => { |
| 93 | setAwards([]); |
| 94 | }) |
| 95 | .finally(() => setLoading(false)); |
| 96 | }, [q, sortParam]); |
| 97 | |
| 98 | const isFiltered = !!(q || sortParam); |
| 99 | const totalAwards = awards.reduce((n, a) => n + a.amount, 0); |
| 100 | |
| 101 | if (loading) { |
| 102 | return <AwardsSkeleton />; |
| 103 | } |
| 104 | |
| 105 | return ( |
| 106 | <div> |
| 107 | {} |
| 108 | <div className="mt-8"> |
| 109 | <form method="get" action="/spending" className="contents"> |
| 110 | <SearchFilterBar |
| 111 | count={awards.length} |
| 112 | countLabel="award" |
| 113 | clearHref={isFiltered ? "/spending" : undefined} |
| 114 | > |
| 115 | <FilterField label="Search awards" span="2"> |
| 116 | <FilterInput |
| 117 | name="q" |
| 118 | defaultValue={q} |
| 119 | placeholder="Agency, recipient, or program…" |
| 120 | aria-label="Search federal awards by agency, recipient, or program" |
| 121 | /> |
| 122 | </FilterField> |
| 123 | <FilterField label="Sort by"> |
| 124 | <FilterSelect |
| 125 | name="sort" |
| 126 | defaultValue={sortParam} |
| 127 | aria-label="Sort awards" |
| 128 | options={[ |
| 129 | { value: "", label: "Largest first" }, |
| 130 | { value: "date", label: "Most recent" }, |
| 131 | { value: "agency", label: "By agency" }, |
| 132 | ]} |
| 133 | /> |
| 134 | </FilterField> |
| 135 | <FilterSubmit /> |
| 136 | </SearchFilterBar> |
| 137 | </form> |
| 138 | </div> |
| 139 | |
| 140 | {} |
| 141 | <div className="mt-12"> |
| 142 | <SectionHeader |
| 143 | eyebrow="Federal awards" |
| 144 | title="Contracts, grants, and assistance" |
| 145 | description={`${awards.length} award${awards.length === 1 ? "" : "s"}${isFiltered ? " match" : ""} from USAspending.gov, largest first. The ${awards.length} shown here total ${formatUsd(totalAwards, { compact: true })}.`} |
| 146 | /> |
| 147 | {awards.length === 0 ? ( |
| 148 | <Card className="p-8 text-center"> |
| 149 | <p className="display text-2xl text-paper">No awards match.</p> |
| 150 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 151 | Adjust the search above. Federal awards appear as the ingest runs. |
| 152 | </p> |
| 153 | </Card> |
| 154 | ) : ( |
| 155 | <AwardsTable awards={awards} /> |
| 156 | )} |
| 157 | <p className="label mt-4 text-ash-2"> |
| 158 | Source: USAspending.gov. Figures reflect recorded award obligations; |
| 159 | the source link on each row opens the official record. |
| 160 | </p> |
| 161 | </div> |
| 162 | </div> |
| 163 | ); |
| 164 | } |