From 5ee5ee24cfee7cd1fb562eb816f076bef8a95572 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Mon, 27 Jul 2026 11:52:33 +1000 Subject: [PATCH] feat(orders): expand a row to see the receipt it came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/transactions/page.tsx | 38 ++++++++- src/components/edit-transaction-modal.tsx | 86 +------------------- src/components/order-details.tsx | 95 +++++++++++++++++++++++ 3 files changed, 131 insertions(+), 88 deletions(-) create mode 100644 src/components/order-details.tsx diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index 44acc72..b5c784b 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -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[0]["prefill"]; title?: string } | null>(null); const [editModal, setEditModal] = useState(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>(new Set()); const [showImportModal, setShowImportModal] = useState(false); const [paymentModal, setPaymentModal] = useState(null); const [rulePrompt, setRulePrompt] = useState<{ @@ -930,8 +934,8 @@ function TransactionsContent() { No transactions found ) : ( data.data.map((t) => ( + {formatDate(t.transaction_date)} {formatDate(t.created_at)} -

{t.description}

+
+ {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. + + )} +

{t.description}

+
{t.notes ? (

{t.notes}

) : t.order_platform === "uber" && routeSummary(t.order_route) && ( @@ -1086,6 +1109,15 @@ function TransactionsContent() { + {expanded.has(t.id) && ( + + + + + + + )} +
)) )} diff --git a/src/components/edit-transaction-modal.tsx b/src/components/edit-transaction-modal.tsx index 707bd48..0f2d5f9 100644 --- a/src/components/edit-transaction-modal.tsx +++ b/src/components/edit-transaction-modal.tsx @@ -8,10 +8,9 @@ import { useRemoveTransactionTag, useTransactionSplits, useTrips, - useOrderReceipt, - type OrderReceipt, } from "@/lib/hooks"; import { SplitModal } from "./split-modal"; +import { OrderDetails } from "./order-details"; import { CATEGORIES, formatCategory } from "@/lib/categories"; import type { TransactionRow, TagRow } from "@/lib/queries"; @@ -87,89 +86,6 @@ function InlineTags({ transactionId, initialTags }: { transactionId: number; ini ); } -const PLATFORM_LABEL: Record = { - 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. - */ -function OrderDetails({ transactionId, currency }: { transactionId: number; currency: string | null }) { - 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 ( -
-
-

- Order details - {receipt.platform && ( - {PLATFORM_LABEL[receipt.platform] ?? receipt.platform} - )} -

- {receipt.card_last4 && ( - card ••••{receipt.card_last4} - )} -
- - {items.length > 0 ? ( -
    - {items.map((it, i) => ( -
  • - {it.qty}× - - {it.description} - {it.options && it.options.length > 0 && ( - {it.options.join(" · ")} - )} - - {fmt(Number(it.amount))} -
  • - ))} -
- ) : ( - // 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. -

- No itemised list on this receipt -

- )} - - {route.length > 0 && ( -
- {route.map((pt, i) => ( -
- - {pt.label} - {pt.time && {pt.time}} - - {pt.address} -
- ))} -
- )} - - {receipt.order_reference && !receipt.order_reference.startsWith("msg:") && ( -

- {receipt.order_reference} -

- )} -
- ); -} - export function EditTransactionModal({ transaction, onClose, diff --git a/src/components/order-details.tsx b/src/components/order-details.tsx new file mode 100644 index 0000000..fa309dd --- /dev/null +++ b/src/components/order-details.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks"; + +const PLATFORM_LABEL: Record = { + 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 ( +
+
+

+ Order details + {receipt.platform && ( + {PLATFORM_LABEL[receipt.platform] ?? receipt.platform} + )} +

+ {receipt.card_last4 && ( + card ••••{receipt.card_last4} + )} +
+ + {items.length > 0 ? ( +
    + {items.map((it, i) => ( +
  • + {it.qty}× + + {it.description} + {it.options && it.options.length > 0 && ( + {it.options.join(" · ")} + )} + + {fmt(Number(it.amount))} +
  • + ))} +
+ ) : ( + // 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. +

+ No itemised list on this receipt +

+ )} + + {route.length > 0 && ( +
+ {route.map((pt, i) => ( +
+ + {pt.label} + {pt.time && {pt.time}} + + {pt.address} +
+ ))} +
+ )} + + {receipt.order_reference && !receipt.order_reference.startsWith("msg:") && ( +

+ {receipt.order_reference} +

+ )} +
+ ); +}