shared page: expandable receipt details on split transactions
ci / lint-test (push) Successful in 57s

The item list existed only on the owner's transactions page — the person
a meal or order was split WITH could see the amount but never what was in
it. getSharedTransactions now carries the same order_platform LATERAL the
transactions page uses, and rows with a receipt get the same disclosure
arrow + OrderDetails expansion. The shared viewer is a split participant,
so canAccessTransactions already authorises the order API for them; meal
rows keep the verdict UI (the partner records their own), bridge rows
stay review-free.
This commit is contained in:
2026-08-10 18:27:16 +10:00
parent 7f8e363b5b
commit 0e5422919e
2 changed files with 45 additions and 4 deletions
+33 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { Fragment, useState, useRef, useEffect } from "react";
import { import {
useSharedTransactions, useSharedTransactions,
useParticipantBalances, useParticipantBalances,
@@ -16,6 +16,7 @@ import {
} from "@/lib/hooks"; } from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries"; import type { SharedTransactionRow } from "@/lib/queries";
import { EditTransactionModal } from "@/components/edit-transaction-modal"; import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { OrderDetails } from "@/components/order-details";
import { formatCategory } from "@/lib/categories"; import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS } from "@/lib/category-colors"; import { CATEGORY_COLORS } from "@/lib/category-colors";
@@ -370,6 +371,10 @@ export default function SharedPage() {
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null); const [showHistory, setShowHistory] = useState<number | null>(null);
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null); const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
// Receipt expansion, same pattern as the transactions page. The shared
// viewer is a split participant, so /api/transactions/[id]/order
// authorises them — the item list is part of what was shared.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -543,11 +548,28 @@ export default function SharedPage() {
{(transactions as SharedTransactionRow[]).map((tx) => { {(transactions as SharedTransactionRow[]).map((tx) => {
const splits = Array.isArray(tx.splits) ? tx.splits : []; const splits = Array.isArray(tx.splits) ? tx.splits : [];
return ( return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30"> <Fragment key={tx.id}>
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td> <td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td> <td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80"> <td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p> <div className="flex items-start gap-1.5">
{tx.order_platform && (
<button
onClick={() => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(tx.id)) next.delete(tx.id); else next.add(tx.id);
return next;
})}
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
title={expanded.has(tx.id) ? "Hide receipt" : "Show the receipt this came from"}
aria-expanded={expanded.has(tx.id)}
>
{expanded.has(tx.id) ? "▾" : "▸"}
</button>
)}
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
</div>
{tx.effective_merchant && ( {tx.effective_merchant && (
<p className="text-xs text-zinc-500 break-words">{tx.description}</p> <p className="text-xs text-zinc-500 break-words">{tx.description}</p>
)} )}
@@ -615,6 +637,14 @@ export default function SharedPage() {
</button> </button>
</td> </td>
</tr> </tr>
{expanded.has(tx.id) && (
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
<td colSpan={8} className="px-4 py-3">
<OrderDetails transactionId={tx.id} currency={tx.currency ?? null} bare />
</td>
</tr>
)}
</Fragment>
); );
})} })}
</tbody> </tbody>
+12 -1
View File
@@ -866,6 +866,11 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
COALESCE(t.owner_id, s.owner_id) as owner_id, COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name, p_owner.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at, COALESCE(src.created_at, t.created_at) as created_at,
-- Receipt flag, same LATERAL the transactions page uses: the shared
-- viewer is a split participant, so the order API authorises them —
-- without the flag here the disclosure arrow (and the item list behind
-- it) existed only on the owner's page.
order_ctx.platform as order_platform,
json_agg(json_build_object( json_agg(json_build_object(
'split_id', ts.id, 'split_id', ts.id,
'participant_id', ts.participant_id, 'participant_id', ts.participant_id,
@@ -879,6 +884,12 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN LATERAL (
SELECT em.platform
FROM expense_metadata em
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
LIMIT 1
) order_ctx ON true
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
WHERE ( WHERE (
( (
@@ -892,7 +903,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
AND ${EXCLUDE_RECONCILED_SOURCE} AND ${EXCLUDE_RECONCILED_SOURCE}
${tagClause} ${tagClause}
${participantClause} ${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at, order_ctx.platform
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
`, params); `, params);