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:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useSearchParams } from "next/navigation";
|
||||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
|
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
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 { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
import { CsvImportModal } from "@/components/csv-import-modal";
|
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||||
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
|
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
|
||||||
|
import { OrderDetails } from "@/components/order-details";
|
||||||
import type { RuleRow } from "@/lib/hooks";
|
import type { RuleRow } from "@/lib/hooks";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
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 [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 [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||||
const [editModal, setEditModal] = useState<TransactionRow | 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 [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
||||||
const [rulePrompt, setRulePrompt] = useState<{
|
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>
|
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||||
) : (
|
) : (
|
||||||
data.data.map((t) => (
|
data.data.map((t) => (
|
||||||
|
<Fragment key={t.id}>
|
||||||
<tr
|
<tr
|
||||||
key={t.id}
|
|
||||||
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
|
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
|
||||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
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 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 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
|
||||||
<td className="p-2 max-w-xs">
|
<td className="p-2 max-w-xs">
|
||||||
|
<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>
|
<p className="truncate" title={t.description}>{t.description}</p>
|
||||||
|
</div>
|
||||||
{t.notes ? (
|
{t.notes ? (
|
||||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
|
<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) && (
|
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
|
||||||
@@ -1086,6 +1109,15 @@ function TransactionsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import {
|
|||||||
useRemoveTransactionTag,
|
useRemoveTransactionTag,
|
||||||
useTransactionSplits,
|
useTransactionSplits,
|
||||||
useTrips,
|
useTrips,
|
||||||
useOrderReceipt,
|
|
||||||
type OrderReceipt,
|
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
import { SplitModal } from "./split-modal";
|
import { SplitModal } from "./split-modal";
|
||||||
|
import { OrderDetails } from "./order-details";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import type { TransactionRow, TagRow } from "@/lib/queries";
|
import type { TransactionRow, TagRow } from "@/lib/queries";
|
||||||
|
|
||||||
@@ -87,89 +86,6 @@ function InlineTags({ transactionId, initialTags }: { transactionId: number; ini
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
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 (
|
|
||||||
<div className="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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditTransactionModal({
|
export function EditTransactionModal({
|
||||||
transaction,
|
transaction,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user