● Zion Boggan
repos/Docket/components/spending-awards-browser.tsx
zionboggan.com ↗
164 lines · tsx
History for this file →
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
}