Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions src/components/molecules/bill-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AmountDisplay } from "@/components/atoms/amount-display";
import { BillStatusIndicator } from "@/components/atoms/bill-status-indicator";
import { Badge } from "@/components/ui/badge";
import { TableCell, TableRow } from "@/components/ui/table";
import { categoryLabel } from "@/lib/labels";
import type { BillRow as BillRowType } from "@/queries/recurring";

Expand All @@ -24,11 +25,11 @@ export function BillRow({ bill, onSelect }: BillRowProps) {
// unreachable by keyboard, and wrapping the row in a <button> would
// destroy the table semantics screen readers need to pair cells with
// their column headers.
<tr
<TableRow
onClick={onSelect}
className="cursor-pointer border-b border-border/50 last:border-b-0 hover:bg-accent"
className="cursor-pointer border-border/50 hover:bg-accent"
>
<td className="px-3 py-2">
<TableCell className="px-3 py-2">
<button
type="button"
onClick={(e) => {
Expand All @@ -40,27 +41,27 @@ export function BillRow({ bill, onSelect }: BillRowProps) {
>
{bill.name}
</button>
</td>
<td className="max-w-[160px] truncate px-3 py-2 text-xs text-muted-foreground">
</TableCell>
<TableCell className="max-w-[160px] truncate px-3 py-2 text-xs text-muted-foreground">
{categoryLabel(bill.categoryName)}
</td>
<td className="px-3 py-2 text-right">
</TableCell>
<TableCell className="px-3 py-2 text-right">
{bill.averageAmount !== null && (
<AmountDisplay amount={bill.averageAmount} absolute />
)}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="px-3 py-2">
{bill.frequency && (
<Badge variant="outline" className="text-xs font-normal">
{FREQUENCY_LABELS[bill.frequency] ?? bill.frequency}
</Badge>
)}
</td>
<td className="px-3 py-2">
</TableCell>
<TableCell className="px-3 py-2">
<span className="flex justify-end">
<BillStatusIndicator status={bill.status} relativeDateLabel={bill.relativeDateLabel} />
</span>
</td>
</tr>
</TableCell>
</TableRow>
);
}
39 changes: 29 additions & 10 deletions src/components/molecules/date-range-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";

export interface DatePresetOption {
Expand Down Expand Up @@ -58,8 +59,12 @@ export function DateRangePopover({
}: DateRangePopoverProps) {
const [open, setOpen] = useState(false);

function handlePreset(id: string) {
onSelectPreset(id);
// Base UI hands back an array even for single select, and an empty one when
// the active item is clicked again. Re-picking the current preset should just
// close the popover, not clear the range.
function handlePreset(groupValue: string[]) {
const id = groupValue[0] ?? selectedId;
if (id) onSelectPreset(id);
setOpen(false);
}

Expand Down Expand Up @@ -90,19 +95,33 @@ export function DateRangePopover({
<ChevronDown className="ml-1 h-3 w-3 opacity-60" />
</PopoverTrigger>
<PopoverContent className="w-[240px] p-2" align={align}>
<div className="flex flex-col">
{/* A roving-focus group rather than a stack of buttons, so the presets
answer to arrow keys instead of costing one Tab stop each. This
cannot be a DropdownMenu: the custom-range inputs below share the
popover, and Base UI's menu typeahead has no input guard, so typing
a date would move the menu selection instead. */}
<ToggleGroup
value={selectedId ? [selectedId] : []}
onValueChange={handlePreset}
orientation="vertical"
spacing={0}
aria-label="Date range presets"
className="w-full"
>
{presets.map((preset) => (
<button
<ToggleGroupItem
key={preset.id}
type="button"
onClick={() => handlePreset(preset.id)}
className="flex h-8 items-center justify-between rounded-md px-2 text-sm hover:bg-muted"
value={preset.id}
className="h-8 w-full justify-between rounded-md px-2 text-sm font-normal"
>
{preset.label}
{selectedId === preset.id && <Check className="h-3.5 w-3.5" />}
</button>
<Check
aria-hidden
className={cn("h-3.5 w-3.5", selectedId !== preset.id && "invisible")}
/>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
<Separator className="my-2" />
<p className="px-2 pb-1 text-xs text-muted-foreground">Custom range</p>
<div className="flex items-center gap-1.5 px-1">
Expand Down
68 changes: 36 additions & 32 deletions src/components/organisms/bill-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

import { useState } from "react";
import { BillRow } from "@/components/molecules/bill-row";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { BillDetailSheet } from "@/components/organisms/bill-detail-sheet";
import type { BillRow as BillRowType } from "@/queries/recurring";
import type { CategoryGroup } from "@/queries/categories";
Expand All @@ -16,38 +23,35 @@ export function BillList({ bills, categoryGroups }: BillListProps) {

return (
<div>
{/* The columns need roughly 560px to stay legible. Previously they were
fixed widths with no breakpoint and no scroll container, so the whole
page scrolled sideways on a phone (body scrollWidth 592 at 390px).
Scrolling inside this container is the pattern Investments uses. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b text-xs font-medium text-muted-foreground">
<th scope="col" className="px-3 py-2 text-left font-medium">
Name
</th>
<th scope="col" className="px-3 py-2 text-left font-medium">
Category
</th>
<th scope="col" className="px-3 py-2 text-right font-medium">
Amount
</th>
<th scope="col" className="px-3 py-2 text-left font-medium">
Frequency
</th>
<th scope="col" className="px-3 py-2 text-right font-medium">
Status
</th>
</tr>
</thead>
<tbody>
{bills.map((bill) => (
<BillRow key={bill.id} bill={bill} onSelect={() => setSelected(bill)} />
))}
</tbody>
</table>
</div>
{/* The columns need roughly 560px to stay legible; below that they scroll
inside Table's own container rather than pushing the page sideways
(body scrollWidth 592 at 390px, before this had a scroll parent). */}
<Table className="min-w-[560px]">
<TableHeader>
<TableRow className="text-xs font-medium text-muted-foreground hover:bg-transparent">
<TableHead scope="col" className="text-left font-medium">
Name
</TableHead>
<TableHead scope="col" className="text-left font-medium">
Category
</TableHead>
<TableHead scope="col" className="text-right font-medium">
Amount
</TableHead>
<TableHead scope="col" className="text-left font-medium">
Frequency
</TableHead>
<TableHead scope="col" className="text-right font-medium">
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bills.map((bill) => (
<BillRow key={bill.id} bill={bill} onSelect={() => setSelected(bill)} />
))}
</TableBody>
</Table>

{/* Keyed so opening another bill remounts the sheet and its form
re-seeds, rather than syncing props into state from an effect. */}
Expand Down
77 changes: 42 additions & 35 deletions src/components/organisms/portfolio-reconciliation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { centsToDisplay } from "@/lib/money";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AccountReconciliationRow } from "@/queries/investments";

interface PortfolioReconciliationProps {
Expand Down Expand Up @@ -33,22 +42,21 @@ export function PortfolioReconciliation({ rows }: PortfolioReconciliationProps)
What each account reports, against the holdings Ledgr can itemize.
</p>
</div>
{/* Wide content scrolls inside its own container so the page never
{/* Wide content scrolls inside Table's own container so the page never
scrolls sideways on a phone. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2 text-left font-medium">Account</th>
<th className="px-4 py-2 text-right font-medium">Balance</th>
<th className="px-4 py-2 text-right font-medium">Holdings</th>
<th className="px-4 py-2 text-right font-medium">Cash / unallocated</th>
</tr>
</thead>
<tbody>
<Table className="min-w-[560px]">
<TableHeader>
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground hover:bg-transparent">
<TableHead className="px-4 text-left font-medium">Account</TableHead>
<TableHead className="px-4 text-right font-medium">Balance</TableHead>
<TableHead className="px-4 text-right font-medium">Holdings</TableHead>
<TableHead className="px-4 text-right font-medium">Cash / unallocated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<tr key={row.accountId} className="border-b last:border-b-0">
<td className="px-4 py-2">
<TableRow key={row.accountId}>
<TableCell className="px-4">
{row.accountName}
{/* A balance with no holdings at all is a connector that
itemized nothing, not a cash position -- worth saying so
Expand All @@ -58,37 +66,36 @@ export function PortfolioReconciliation({ rows }: PortfolioReconciliationProps)
not itemized
</span>
)}
</td>
<td className="px-4 py-2 text-right tabular-nums">{centsToDisplay(row.balance)}</td>
<td className="px-4 py-2 text-right tabular-nums">
</TableCell>
<TableCell className="px-4 text-right tabular-nums">{centsToDisplay(row.balance)}</TableCell>
<TableCell className="px-4 text-right tabular-nums">
{centsToDisplay(row.holdingsValue)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
</TableCell>
<TableCell className="px-4 text-right tabular-nums">
{row.cashValue > 0 ? (
centsToDisplay(row.cashValue)
) : (
<span className="text-muted-foreground">&mdash;</span>
)}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
<tfoot>
<tr className="border-t font-medium">
<td className="px-4 py-2">Total</td>
<td className="px-4 py-2 text-right tabular-nums">
</TableBody>
<TableFooter>
<TableRow className="hover:bg-transparent">
<TableCell className="px-4">Total</TableCell>
<TableCell className="px-4 text-right tabular-nums">
{centsToDisplay(totals.balance)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
</TableCell>
<TableCell className="px-4 text-right tabular-nums">
{centsToDisplay(totals.holdingsValue)}
</td>
<td className="px-4 py-2 text-right tabular-nums">
</TableCell>
<TableCell className="px-4 text-right tabular-nums">
{centsToDisplay(totals.cashValue)}
</td>
</tr>
</tfoot>
</table>
</div>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
}
52 changes: 27 additions & 25 deletions src/components/organisms/transaction-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Command,
CommandEmpty,
Expand Down Expand Up @@ -84,6 +91,10 @@ function triggerLabel(label: string, value: string | null, active: boolean): Rea
);
}

/** A radio group needs a value for every row; `null` is not one, so the
* "no type filter" row carries this sentinel and maps back to null. */
const ALL_TYPES = "__all__";

export function TransactionFilters({ accounts, categories, resultCount }: TransactionFiltersProps) {
const { updateFilter, updateFilters, clearFilters, hasFilters, searchParams } =
useSearchParamFilters();
Expand Down Expand Up @@ -474,39 +485,30 @@ export function TransactionFilters({ accounts, categories, resultCount }: Transa
</PopoverContent>
</Popover>

{/* Type */}
<Popover open={typeOpen} onOpenChange={setTypeOpen}>
<PopoverTrigger
{/* Type — a single-select list, which is what a menu radio group is for.
Built from raw buttons it was Tab-only: five stops, no arrow keys. */}
<DropdownMenu open={typeOpen} onOpenChange={setTypeOpen}>
<DropdownMenuTrigger
render={<Button variant={typeValue ? "default" : "outline"} size="sm" className="h-8 text-xs" />}
>
<ArrowLeftRight className="mr-1 h-3.5 w-3.5" />
{triggerLabel("Type", typeValue, !!typeValue)}
<ChevronDown className="ml-1 h-3 w-3 opacity-60" />
</PopoverTrigger>
<PopoverContent className="w-[180px] p-1" align="start">
<div className="flex flex-col">
<button
type="button"
onClick={() => selectType(null)}
className="flex h-8 items-center justify-between rounded-md px-2 text-sm hover:bg-muted"
>
All types
{!typeId && <Check className="h-3.5 w-3.5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[180px]" align="start">
<DropdownMenuRadioGroup
value={typeId ?? ALL_TYPES}
onValueChange={(value) => selectType(value === ALL_TYPES ? null : String(value))}
>
<DropdownMenuRadioItem value={ALL_TYPES}>All types</DropdownMenuRadioItem>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => selectType(value)}
className="flex h-8 items-center justify-between rounded-md px-2 text-sm hover:bg-muted"
>
<DropdownMenuRadioItem key={value} value={value}>
{label}
{typeId === value && <Check className="h-3.5 w-3.5" />}
</button>
</DropdownMenuRadioItem>
))}
</div>
</PopoverContent>
</Popover>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>

{/* Amount */}
<Popover open={amountOpen} onOpenChange={setAmountOpen}>
Expand Down