shared page: expandable receipt details on split transactions
ci / lint-test (push) Successful in 57s
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:
+33
-3
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { OrderDetails } from "@/components/order-details";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
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 [showHistory, setShowHistory] = useState<number | 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 (
|
||||
<div className="space-y-6">
|
||||
@@ -543,11 +548,28 @@ export default function SharedPage() {
|
||||
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
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-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">
|
||||
<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 && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
)}
|
||||
@@ -615,6 +637,14 @@ export default function SharedPage() {
|
||||
</button>
|
||||
</td>
|
||||
</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>
|
||||
|
||||
+12
-1
@@ -866,6 +866,11 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p_owner.name as owner_name,
|
||||
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(
|
||||
'split_id', ts.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 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 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
|
||||
WHERE (
|
||||
(
|
||||
@@ -892,7 +903,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${tagClause}
|
||||
${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
|
||||
`, params);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user