feat(remits): row click opens RemitDrawer (replaces inline CAS expand)

This commit is contained in:
Tyler
2026-06-21 17:03:04 -06:00
parent b606e8c9a2
commit 7427838292
2 changed files with 130 additions and 139 deletions
+67 -125
View File
@@ -1,5 +1,4 @@
import { Fragment, useCallback, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import {
Table,
TableBody,
@@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { PageHeader } from "@/components/PageHeader";
import { TailStatusPill } from "@/components/TailStatusPill";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemittances } from "@/hooks/useRemittances";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
import type { Remittance, RemittanceStatus } from "@/types";
const PAGE_SIZE = 25;
@@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [
{ value: "reconciled", label: "Reconciled" },
];
/**
* One persisted CAS row, rendered as a "code — label" pair plus the
* dollar amount. Lives inside the expanded detail row of a remit so
* the operator can see exactly why the payer adjusted the claim.
*/
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
return (
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
<div className="min-w-0 flex-1">
<div className="mono text-[10.5px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/60">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
);
}
export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
// SP21 Phase 4 Task 4.3: row click → RemitDrawer. The drawer is
// URL-driven (`?remit=ID`) so deep links restore the open remit
// on reload — same pattern as the ClaimDrawer on /claims.
// `remits` is the j/k navigation list (the current page of rows).
// `setRemitId` (REPLACE history, not push) is what j/k uses so a
// single keypress doesn't add a history entry.
const { remitId, open, close, setRemitId } = useRemitDrawerUrlState();
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
sort: "receivedDate",
order: "desc",
@@ -93,15 +75,6 @@ export function Remittances() {
{ paid: 0, adjustments: 0 }
);
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
if (items.length === 0) return null;
@@ -119,19 +92,42 @@ export function Remittances() {
}, [items.length]);
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
// Page-level j/k only fires when the drawer is closed — once
// `?remit=` is set, the drawer's own `useDrawerKeyboard` listener
// owns the j/k keys (with its own wrap-around semantics over
// `remits`). Letting the page-level listener stay active here
// would mean a single `j` keypress both advances the drawer's
// remittance AND bumps the page-level selectedIndex — exactly
// the "double navigation" surprise we want to avoid.
enabled: !helpOpen && items.length > 0 && remitId === null,
onNext: moveNext,
onPrev: movePrev,
onClose: () => setHelpOpen(false),
onToggleHelp: () => setHelpOpen((v) => !v),
});
// j/k navigation through the remits list. The drawer's own keyboard
// handler (useDrawerKeyboard, only attached while the drawer is
// open) uses the same keys with its own wrap-around semantics, so
// page-level nav only fires when the drawer is closed.
const drawerRemits = useMemo(
() => items.map((r) => ({ id: r.id })),
[items],
);
return (
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<RemitDrawer
remitId={remitId}
remits={drawerRemits}
onClose={close}
onNavigate={setRemitId}
onToggleHelp={() => setHelpOpen((v) => !v)}
/>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Remittances"
@@ -204,7 +200,6 @@ export function Remittances() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
@@ -216,92 +211,39 @@ export function Remittances() {
</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
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash",
isSelected && [
"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 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 mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/20 hover:bg-muted/20"
>
<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="eyebrow">
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>
<TableRow
key={`${r.id}-${dataUpdatedAt}`}
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash cursor-pointer drillable",
isSelected && [
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
onClick={() => open(r.id)}
>
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>