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
+35 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useState, useCallback, useRef, useEffect, Suspense, Fragment } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
@@ -10,6 +10,7 @@ import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { CsvImportModal } from "@/components/csv-import-modal";
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
import { OrderDetails } from "@/components/order-details";
import type { RuleRow } from "@/lib/hooks";
function formatDate(d: string) {
@@ -556,6 +557,9 @@ function TransactionsContent() {
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
// Rows expanded to show the ingested receipt. A set, not a single id: the
// point is comparing several orders without losing your place.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [showImportModal, setShowImportModal] = useState(false);
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
@@ -930,8 +934,8 @@ function TransactionsContent() {
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<Fragment key={t.id}>
<tr
key={t.id}
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
selected.has(t.id) ? "bg-zinc-800/40" : ""
}`}
@@ -947,7 +951,26 @@ function TransactionsContent() {
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
<td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p>
<div className="flex items-start gap-1.5">
{t.order_platform && (
// Only where there IS a receipt behind the row. A
// disclosure arrow on every transaction would promise
// detail that mostly does not exist.
<button
onClick={() => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
return next;
})}
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
title={expanded.has(t.id) ? "Hide receipt" : "Show the receipt this came from"}
aria-expanded={expanded.has(t.id)}
>
{expanded.has(t.id) ? "▾" : "▸"}
</button>
)}
<p className="truncate" title={t.description}>{t.description}</p>
</div>
{t.notes ? (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
@@ -1086,6 +1109,15 @@ function TransactionsContent() {
</button>
</td>
</tr>
{expanded.has(t.id) && (
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
<td />
<td colSpan={10} className="px-4 py-3">
<OrderDetails transactionId={t.id} currency={t.currency ?? null} bare />
</td>
</tr>
)}
</Fragment>
))
)}
</tbody>