diff --git a/components/db/lobbying.ts b/components/db/lobbying.ts index fabae0159..53b428733 100644 --- a/components/db/lobbying.ts +++ b/components/db/lobbying.ts @@ -276,19 +276,98 @@ export type BillRow = { async function fetchLobbyingBillSummaries( court: number ): Promise> { - const snap = await getDoc( - doc(firestore, LOBBYING_STATS_COLLECTION, `billSummaries_${court}`) + const snap = await getDocs( + collection( + firestore, + LOBBYING_STATS_COLLECTION, + `billSummaries_${court}`, + "bills" + ) ) - if (!snap.exists()) return {} - const raw = snap.data() as { data?: string } - if (!raw.data) return {} - return JSON.parse(raw.data) as Record + const result: Record = {} + snap.docs.forEach(d => { + result[d.id] = d.data() as BillSummaryEntry + }) + return result } export function useLobbyingBillSummaries(court: number) { return useAsync(fetchLobbyingBillSummaries, [court]) } +// ── Client / firm summaries (precomputed server-side; see writer.py and +// seedLobbyingStats.ts). Replaces client-side derivation over +// useLobbyingAllRegistrants(), which only fetches the first 2,000 of +// 25,000+ registrant docs and was silently showing an incomplete list. ──── + +export type ClientSummaryFirm = { + entityName: string + entityNameNorm: string + compensation: number | null +} + +export type ClientSummaryRow = { + clientName: string + clientNameNorm: string + totalCompensation: number | null + registrantCount: number + firms: ClientSummaryFirm[] +} + +export type FirmSummaryRow = { + entityName: string + entityNameNorm: string + regType: string + years: number[] + clientCount: number +} + +async function fetchClientSummaries(): Promise { + const snap = await getDocs( + collection( + firestore, + LOBBYING_STATS_COLLECTION, + "clientSummaries", + "clients" + ) + ) + return snap.docs.map(d => d.data() as ClientSummaryRow) +} + +async function fetchClientSummary( + clientNameNorm: string +): Promise { + const snap = await getDoc( + doc( + firestore, + LOBBYING_STATS_COLLECTION, + "clientSummaries", + "clients", + encodeURIComponent(clientNameNorm) + ) + ) + return snap.exists() ? (snap.data() as ClientSummaryRow) : undefined +} + +async function fetchFirmSummaries(): Promise { + const snap = await getDocs( + collection(firestore, LOBBYING_STATS_COLLECTION, "firmSummaries", "firms") + ) + return snap.docs.map(d => d.data() as FirmSummaryRow) +} + +export function useLobbyingClientSummaries() { + return useAsync(fetchClientSummaries, []) +} + +export function useLobbyingClientSummary(clientNameNorm: string) { + return useAsync(fetchClientSummary, [clientNameNorm]) +} + +export function useLobbyingFirmSummaries() { + return useAsync(fetchFirmSummaries, []) +} + export function useLobbyingBillRows(courts: number[]) { const courtsKey = courts.join(",") return useAsync( diff --git a/components/lobbying/LobbyingBillCard.tsx b/components/lobbying/LobbyingBillCard.tsx index a22c713e1..96b90f4b6 100644 --- a/components/lobbying/LobbyingBillCard.tsx +++ b/components/lobbying/LobbyingBillCard.tsx @@ -2,9 +2,13 @@ import React from "react" import { useTranslation } from "next-i18next" import { useLobbyingFilingsForBill } from "components/db/lobbying" import { LobbyingFilingsTable } from "./LobbyingFilingsTable" +import { LobbyingPaginationBar } from "./LobbyingPaginationBar" +import { usePagination } from "./usePagination" import { MAPLE_COLORS } from "./chartTheme" import { normalizePosition } from "./LobbyingPositionChip" +const PAGE_SIZE = 10 + interface LobbyingBillCardProps { court: number billId: string @@ -22,6 +26,10 @@ export const LobbyingBillCard: React.FC = ({ status, error } = useLobbyingFilingsForBill(court, billId) + const { page, setPage, pageItems, totalPages, totalItems } = usePagination( + filings ?? [], + PAGE_SIZE + ) if (status === "loading" || status === "not-requested") { return ( @@ -60,10 +68,10 @@ export const LobbyingBillCard: React.FC = ({ return (
- {t("lobbying:titles.overview")} - - {t("lobbying:billCard.filingCount_other", { count: total })} - + {t("lobbying:billCard.title")} + + {t("lobbying:billCard.viewAll")} +
@@ -87,17 +95,21 @@ export const LobbyingBillCard: React.FC = ({
+ - - - {t("lobbying:billCard.viewAll")} - ) } @@ -211,11 +223,6 @@ const titleStyle: React.CSSProperties = { letterSpacing: "0.06em" } -const countStyle: React.CSSProperties = { - fontSize: 12, - color: MAPLE_COLORS.textMuted -} - const barContainerStyle: React.CSSProperties = { display: "flex", height: 8, @@ -245,10 +252,9 @@ const legendItemStyle: React.CSSProperties = { } const viewAllLinkStyle: React.CSSProperties = { - display: "block", - fontSize: 13, + fontSize: 12, fontWeight: 600, color: MAPLE_COLORS.primary, textDecoration: "none", - marginTop: "0.5rem" + whiteSpace: "nowrap" } diff --git a/components/lobbying/LobbyingFilingsTable.tsx b/components/lobbying/LobbyingFilingsTable.tsx index 103fdb329..1366e484a 100644 --- a/components/lobbying/LobbyingFilingsTable.tsx +++ b/components/lobbying/LobbyingFilingsTable.tsx @@ -14,6 +14,7 @@ interface LobbyingFilingsTableProps { showActivity?: boolean maxRows?: number onViewAll?: () => void + bordered?: boolean } export const LobbyingFilingsTable: React.FC = ({ @@ -24,7 +25,8 @@ export const LobbyingFilingsTable: React.FC = ({ showAmount = true, showActivity = false, maxRows, - onViewAll + onViewAll, + bordered = false }) => { const { t } = useTranslation("lobbying") const rows = maxRows ? filings.slice(0, maxRows) : filings @@ -39,7 +41,17 @@ export const LobbyingFilingsTable: React.FC = ({ } return ( -
+
@@ -71,8 +83,30 @@ export const LobbyingFilingsTable: React.FC = ({ )} )} - {showClient && } - {showFirm && } + {showClient && ( + + )} + {showFirm && ( + + )} {showActivity && (
{f.clientName}{f.entityName} + + {f.clientName} + + + + {f.entityName} + + {f.activityTitle || "—"} @@ -116,8 +150,10 @@ export const LobbyingFilingsTable: React.FC = ({ const tableStyle: React.CSSProperties = { fontSize: 13, - color: MAPLE_COLORS.textBody -} + color: MAPLE_COLORS.textBody, + "--bs-table-bg": "var(--bs-gray-100)", + "--bs-table-hover-bg": "rgba(15, 23, 42, 0.06)" +} as React.CSSProperties const theadRowStyle: React.CSSProperties = { fontSize: 11, diff --git a/components/lobbying/LobbyingPaginationBar.tsx b/components/lobbying/LobbyingPaginationBar.tsx index 7c6254a45..18f75512d 100644 --- a/components/lobbying/LobbyingPaginationBar.tsx +++ b/components/lobbying/LobbyingPaginationBar.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect, useState } from "react" import { useTranslation } from "next-i18next" import { MAPLE_COLORS } from "./chartTheme" @@ -8,6 +8,7 @@ interface Props { totalItems: number pageSize: number onPage: (p: number) => void + itemLabel?: string } export function LobbyingPaginationBar({ @@ -15,19 +16,41 @@ export function LobbyingPaginationBar({ totalPages, totalItems, pageSize, - onPage + onPage, + itemLabel }: Props) { const { t } = useTranslation("lobbying") + const [pageInput, setPageInput] = useState(String(page)) + + // Keep the typed value in sync when the page changes externally (Prev/ + // Next, or a filter change resetting to page 1) — but not while the user + // is actively typing a replacement value. + useEffect(() => { + setPageInput(String(page)) + }, [page]) + if (totalPages <= 1) return null const start = (page - 1) * pageSize + 1 const end = Math.min(page * pageSize, totalItems) + function commitPageInput() { + const parsed = parseInt(pageInput, 10) + if (Number.isNaN(parsed)) { + setPageInput(String(page)) + return + } + const clamped = Math.min(Math.max(parsed, 1), totalPages) + setPageInput(String(clamped)) + if (clamped !== page) onPage(clamped) + } + return (
{start}–{end} of {totalItems} + {itemLabel ? ` ${itemLabel}` : ""} -
+
- - {page} / {totalPages} + + setPageInput(e.target.value.replace(/[^0-9]/g, ""))} + onBlur={commitPageInput} + onKeyDown={e => { + if (e.key === "Enter") { + e.currentTarget.blur() + } + }} + aria-label={t("pagination.pageNumber")} + style={pageInputStyle} + /> + / {totalPages}