feat(frontend): j/k row nav + ? cheatsheet on Acks and Remittances
This commit is contained in:
+224
-167
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -14,7 +14,9 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { useRemittances } from "@/hooks/useRemittances";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
||||
@@ -57,6 +59,11 @@ export function Remittances() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
// Row navigation (j/k) lives in parallel with `expanded`; selection
|
||||
// is purely a focus-state indicator and does NOT auto-expand the
|
||||
// row (so j/k doesn't fight the existing click-to-expand gesture).
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||
sort: "receivedDate",
|
||||
@@ -83,181 +90,231 @@ export function Remittances() {
|
||||
});
|
||||
};
|
||||
|
||||
const moveNext = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return 0;
|
||||
return (i + 1) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
const movePrev = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return items.length - 1;
|
||||
// Add items.length before the modulo so the negative index
|
||||
// (first row → wrap to last) wraps correctly.
|
||||
return (i - 1 + items.length) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
// `enabled: !helpOpen` so j/k yields to the cheatsheet while it's
|
||||
// open (the cheatsheet's own dismiss listener still closes it on
|
||||
// any non-`?` keypress). See Acks.tsx for the full rationale.
|
||||
useRowKeyboard({
|
||||
enabled: !helpOpen && items.length > 0,
|
||||
onNext: moveNext,
|
||||
onPrev: movePrev,
|
||||
onClose: () => setHelpOpen(false),
|
||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Remittances
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Electronic remittance advice from payers, matched to submitted claims.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load remittances from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Remits
|
||||
<>
|
||||
<KeyboardCheatsheet
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
/>
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Remittances
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Total paid
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Adjustments
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Electronic remittance advice from payers, matched to submitted claims.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="surface rounded-xl p-4">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="Remittances · awaiting first 835"
|
||||
message="Drop an 835 file on the Upload page to populate this list."
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load remittances from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Adjustment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Received</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
className={cn("animate-row-flash")}
|
||||
onClick={() =>
|
||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||
}
|
||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{hasAdjustments ? (
|
||||
isOpen ? (
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && hasAdjustments ? (
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Remits
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Total paid
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Adjustments
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl p-4">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="Remittances · awaiting first 835"
|
||||
message="Drop an 835 file on the Upload page to populate this list."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Adjustment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Received</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r, idx) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||
className="bg-muted/30 hover:bg-muted/30"
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"animate-row-flash",
|
||||
isSelected && [
|
||||
// Same accent-tinted highlight as Acks:
|
||||
// background tint + inset ring + left
|
||||
// accent strip, layered on top of the
|
||||
// TableRow primitive's
|
||||
// `data-[state=selected]:bg-muted` to
|
||||
// win visually.
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
onClick={() =>
|
||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||
}
|
||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||
>
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Adjustments ({r.adjustments!.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
{r.adjustments!.map((adj, i) => (
|
||||
<AdjustmentRow
|
||||
key={`${adj.group}-${adj.reason}-${i}`}
|
||||
adj={adj}
|
||||
<TableCell className="text-muted-foreground">
|
||||
{hasAdjustments ? (
|
||||
isOpen ? (
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="px-4 pb-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data?.total ?? 0}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isOpen && hasAdjustments ? (
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||
className="bg-muted/30 hover:bg-muted/30"
|
||||
>
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Adjustments ({r.adjustments!.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
{r.adjustments!.map((adj, i) => (
|
||||
<AdjustmentRow
|
||||
key={`${adj.group}-${adj.reason}-${i}`}
|
||||
adj={adj}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="px-4 pb-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data?.total ?? 0}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user