feat(orders): expand a row to see the receipt it came from
ci / lint-test (push) Failing after 44s

Enrichment is the point of the ingestion pipeline (DECISIONS ING-8) — a bank
statement gives a date, an amount and a mangled descriptor, and everything that
makes a transaction understandable arrives by email. It was all reachable only
by opening the edit modal, which is a strange place to look for "what was in
this order".

Rows with a receipt behind them get a disclosure arrow in the description cell;
clicking expands an inline panel with the line items, the pick-up and drop-off
stops, the card tail and the provider's reference. Several rows can be open at
once — the point is comparing orders without losing your place.

The arrow appears only where `order_platform` is set. Putting one on every
transaction would promise detail that mostly does not exist.

OrderDetails moves out of edit-transaction-modal.tsx into its own component so
both surfaces render the same thing; `bare` drops the modal's top border when
it sits in a table row.
This commit is contained in:
2026-07-27 11:52:33 +10:00
parent 3bb67f370d
commit 5ee5ee24cf
3 changed files with 131 additions and 88 deletions
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks";
const PLATFORM_LABEL: Record<string, string> = {
doordash: "DoorDash",
ubereats: "Uber Eats",
uber: "Uber",
};
/**
* The receipt behind a delivery order: what was actually bought, and where it
* went. All of it was already stored at ingest and none of it was reachable —
* the row showed a merchant and a total and nothing else.
*
* Read-only on purpose. This is what a provider sent, not something to edit.
*/
export function OrderDetails({
transactionId,
currency,
bare = false,
}: {
transactionId: number;
currency: string | null;
/** Drop the top border and heading spacing when embedded in a table row. */
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
if (isLoading || !receipt) return null;
const cur = receipt.currency ?? currency ?? "AUD";
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
const route: OrderReceipt["route"] = receipt.route ?? [];
return (
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
<div className="flex items-baseline justify-between mb-2">
<p className="text-xs text-zinc-500">
Order details
{receipt.platform && (
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
)}
</p>
{receipt.card_last4 && (
<span className="text-xs text-zinc-600">card {receipt.card_last4}</span>
)}
</div>
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
<span className="text-zinc-300 flex-1 min-w-0">
{it.description}
{it.options && it.options.length > 0 && (
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
)}
</span>
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
</li>
))}
</ul>
) : (
// Uber itemises groceries but not restaurant orders, and orders taken
// before this was parsed have none either. Say which, rather than
// showing an empty list that reads like a bug.
<p className="text-xs text-zinc-600 italic mb-3">
No itemised list on this receipt
</p>
)}
{route.length > 0 && (
<div className="space-y-1">
{route.map((pt, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 shrink-0 w-24">
{pt.label}
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
</span>
<span className="text-zinc-400 flex-1">{pt.address}</span>
</div>
))}
</div>
)}
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
{receipt.order_reference}
</p>
)}
</div>
);
}