Files
finance-app/src/app/trips/[id]/page.tsx
T
siddharthd 9e4b518f57
ci / lint-test (push) Successful in 48s
Split a trip into its two economies, so travel stops being a 60% slab
travel dominated every trip page and said nothing. The tempting fix is a finer
travel taxonomy, which needs a hand-maintained merchant list — the trap #19
already describes — and it is also the wrong diagnosis.

travel is the only category that spans both phases of a trip. Every other one is
100% on-the-ground: on Europe 2026, dining, transport, entertainment, groceries
and shopping are all exactly $0.00 before departure. The chart was not bad, it
was two economies stacked into one, and travel was the only thing visible in the
union.

So split on start_date and use the axis that carries information in each phase.
Booked ahead ($22,050.51, 57%) is all flights and stays, so merchant is the axis
— Agoda $4,490, Air India $3,454, Luxury Escapes $3,284. On the ground
($16,946.94) travel falls to $8,241 among dining $4,452 and transport $2,938, and
category is finally worth charting.

The hero is the ratio, not a lone total, with the on-ground daily rate beside it
— the only figure comparable between trips, since totals are not: Europe
$677.88/day against Auckland $83.39. A trip with near-zero committed spend says
so, because Sonu + Sunny's $184.84 is a filing artefact (both legs' bookings sit
on the first trip), not a cheap trip.

Two dataviz rules this page was breaking. Category bars now use one copper hue
with the name as a direct label: the per-bar rainbow double-encoded identity the
label already carries, and the trip subset fails CVD validation on this surface
(other vs shopping at delta-E 5.0 protan, below the floor of 6). And the hero
figure drops the serif and tabular-nums, which read as decoration at that size.
The phase bar is two ordinal steps of one hue, validated with --ordinal against
the card surface, with a 2px gap so the boundary is an edge.

278 passing, build clean. Data verified against the database directly; I could
not render the page in a browser to eyeball the layout.
2026-08-02 22:20:55 +10:00

613 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { use, useState } from "react";
import Link from "next/link";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances, useTrips } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
function fmtDate(d: string | null) {
if (!d) return null;
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function fmt(n: number) {
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* A labelled horizontal magnitude bar.
*
* One hue for every row, never a colour per category. The category name is right
* there as a direct label, so a hue per row would double-encode identity the label
* already carries — and the app's 27-colour CATEGORY_COLORS set fails CVD
* separation on this surface anyway (validated: `other` vs `shopping` at ΔE 5.0
* protan, below the floor). Length carries the magnitude; that is the whole job.
*/
function BarRow({
label,
amount,
count,
max,
sub,
}: {
label: string;
amount: number;
count: number;
max: number;
sub?: string;
}) {
const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0;
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 items-baseline">
<span className="text-sm text-zinc-300 truncate" title={label}>{label}</span>
<span className="text-sm font-mono tabular-nums text-zinc-200">{fmt(amount)}</span>
<div className="col-span-2 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-zinc-800/70 overflow-hidden rounded-sm">
<div
className="h-full transition-[width] duration-500 motion-reduce:transition-none"
style={{ width: `${pct}%`, background: CHART.accent, borderRadius: "0 4px 4px 0" }}
/>
</div>
<span className="text-[11px] text-zinc-600 tabular-nums w-16 text-right shrink-0">
{sub ?? `${count} ${count === 1 ? "charge" : "charges"}`}
</span>
</div>
</div>
);
}
/** A section heading that states what the section is FOR, not just what it holds. */
function SectionHead({ title, note }: { title: string; note: string }) {
return (
<div className="mb-4">
<h3 className="text-sm font-display text-zinc-100">{title}</h3>
<p className="text-xs text-zinc-500 mt-0.5 leading-relaxed">{note}</p>
</div>
);
}
function DailyTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1">
{label ? new Date(label).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }) : ""}
</p>
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
</div>
);
}
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const tripId = Number(id);
const { data: analytics, isLoading } = useTripAnalytics(tripId);
const { data: trip } = useTrip(tripId);
const [tab, setTab] = useState<"overview" | "transactions">("overview");
const [editModal, setEditModal] = useState(false);
// A trip is all the expenses on one trip, so a participant sees every row on
// it, not only their own. The server re-checks participation — this flag is a
// request, not a grant.
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
// Unscoped, deliberately: the trip figure alone cannot tell you whether to pay
// anyone, because a trip whose payment over-covered it reads negative while the
// payer is still in debt overall. This is the number to act on.
const { data: balances = [] } = useParticipantBalances();
// For the only cross-trip figure worth quoting: the daily rate.
const { data: allTrips = [] } = useTrips();
if (isLoading || !analytics) {
return (
<div className="space-y-6">
<div className="h-32 bg-zinc-900 rounded-2xl animate-pulse" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => <div key={i} className="h-24 bg-zinc-900 rounded-xl animate-pulse" />)}
</div>
</div>
);
}
const {
total_spend, transaction_count, num_days, daily_spend, tag_breakdown, participant_splits,
phases, committed_merchants, on_ground_categories, on_ground_daily,
} = analytics;
const t = analytics.trip;
const committed = Number(phases.committed);
const onGround = Number(phases.on_ground);
const total = committed + onGround;
const committedPct = total > 0 ? (committed / total) * 100 : 0;
const onGroundDaily = Number(on_ground_daily);
// No start_date means no knowable departure, so there is no split to draw — the
// query already folds everything into on-ground in that case.
const hasPhases = Boolean(t.start_date);
const maxCommitted = committed_merchants[0]?.amount ?? 1;
const maxOnGround = on_ground_categories[0]?.amount ?? 1;
const meanDaily = daily_spend.length
? daily_spend.reduce((s, d) => s + Number(d.amount), 0) / daily_spend.length
: 0;
// Where this trip's daily burn sits against the others. $677.88/day in Europe
// against $83.39 in Auckland is the kind of thing a single trip page can never
// say on its own, and it is the only figure here that is comparable at all —
// totals are not, because trips differ in length.
const dayRateRank = (() => {
const rated = allTrips
.filter((x) => x.start_date && x.end_date && Number(x.total_spend) > 0)
.map((x) => {
const days = Math.max(1, Math.round(
(new Date(x.end_date!).getTime() - new Date(x.start_date!).getTime()) / 86400000
) + 1);
return { id: x.id, rate: Number(x.total_spend) / days };
})
.sort((a, b) => b.rate - a.rate);
if (rated.length < 2) return null;
const idx = rated.findIndex((x) => x.id === t.id);
if (idx === -1) return null;
if (idx === 0) return `your priciest day-to-day of ${rated.length} trips`;
if (idx === rated.length - 1) return `your cheapest day-to-day of ${rated.length} trips`;
return `${idx + 1}${["st", "nd", "rd"][idx] ?? "th"} priciest of ${rated.length} trips`;
})();
const dateRange = t.start_date && t.end_date
? `${fmtDate(t.start_date)} ${fmtDate(t.end_date)}`
: t.start_date
? `From ${fmtDate(t.start_date)}`
: null;
return (
<div className="space-y-6">
{/* Hero */}
<div
className="relative rounded-2xl overflow-hidden p-6"
style={{
background: `linear-gradient(135deg, ${t.color}28 0%, #18181b 60%)`,
borderLeft: `3px solid ${t.color}`,
}}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href="/trips" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors">
Trips
</Link>
</div>
<h1 className="text-2xl font-bold">{t.name}</h1>
{dateRange && <p className="text-sm text-zinc-400 mt-1">{dateRange}</p>}
{t.description && <p className="text-sm text-zinc-500 mt-1">{t.description}</p>}
</div>
<button
onClick={() => setEditModal(true)}
className="px-3 py-1.5 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm transition-colors flex-shrink-0"
>
Edit Trip
</button>
</div>
</div>
{/* ── Signature: the two economies of a trip ──
The page's thesis, and the answer to "travel is 60% and tells me
nothing". A trip is paid for twice — once in bookings locked in months
ahead, once in daily spending on the ground — and every category except
travel belongs wholly to the second. Showing the ratio first makes the
rest of the page legible; showing a lone total never did. */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">What the trip cost</p>
{/* Sans, not the display face, and proportional figures — a serif or
tabular-nums hero reads as decoration at this size. */}
<p className="text-4xl font-semibold text-zinc-50 mt-1 leading-none">{fmt(Number(total_spend))}</p>
<p className="text-xs text-zinc-500 mt-1.5">
all payers, net of refunds · {transaction_count} charges over {num_days} days
</p>
</div>
{hasPhases && (
<div className="text-right">
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">On the ground</p>
<p className="text-2xl font-semibold text-zinc-100 mt-1 leading-none">
{fmt(onGroundDaily)}<span className="text-sm font-normal text-zinc-500"> / day</span>
</p>
{dayRateRank && <p className="text-xs text-zinc-500 mt-1.5">{dayRateRank}</p>}
</div>
)}
</div>
{hasPhases && total > 0 && (
<div className="mt-6">
{/* Two ordinal steps of one hue, validated against this surface, with a
2px gap so the boundary is a real edge rather than a colour change.
Both segments are direct-labelled, so no legend is needed. */}
<div className="flex gap-[2px] h-2.5" role="img"
aria-label={`${fmt(committed)} committed before departure, ${fmt(onGround)} spent on the ground`}>
<div className="rounded-l-sm rounded-r-[1px]" style={{ width: `${committedPct}%`, background: "#7c4820" }} />
<div className="rounded-r-sm rounded-l-[1px]" style={{ width: `${100 - committedPct}%`, background: "#d28a47" }} />
</div>
<div className="flex flex-wrap justify-between gap-x-6 gap-y-2 mt-3">
<div>
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#7c4820" }} />
{fmt(committed)}
<span className="text-zinc-500"> committed{t.start_date ? ` before ${fmtDate(t.start_date)}` : ""}</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5">
{phases.committed_count} bookings · {committedPct.toFixed(0)}% of the trip
</p>
</div>
<div className="sm:text-right">
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#d28a47" }} />
{fmt(onGround)}
<span className="text-zinc-500"> spent on the ground</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5 sm:ml-0">
{phases.on_ground_count} charges · {(100 - committedPct).toFixed(0)}% of the trip
</p>
</div>
</div>
{committed < 1 && (
// Europe — Sonu + Sunny sits at $184.84 committed against Europe 2026's
// $22,050.51, because the flights and stays for both legs were filed on
// the first trip. Worth saying, or the ratio reads as missing data.
<p className="text-[11px] text-zinc-600 mt-3 pt-3 border-t border-zinc-800/70">
Almost nothing was booked before this trip started its flights and
stays are likely filed against another trip.
</p>
)}
</div>
)}
</div>
{/* Tab bar */}
<div className="flex gap-0 border-b border-zinc-800">
{(["overview", "transactions"] as const).map((tabName) => (
<button
key={tabName}
onClick={() => setTab(tabName)}
className={`px-5 py-2.5 text-sm capitalize transition-colors border-b-2 -mb-px ${
tab === tabName
? "border-current text-white font-medium"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
style={tab === tabName ? { borderColor: t.color } : {}}
>
{tabName}
</button>
))}
</div>
{tab === "overview" && (
<div className="space-y-5">
{/* Daily spend */}
{daily_spend.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
<SectionHead
title="Day by day"
note="Every day money moved, bookings included — the tall early bars are usually the flights."
/>
<span className="text-[11px] text-zinc-500 shrink-0 flex items-center gap-1.5">
<span className="w-4 border-t border-dashed inline-block" style={{ borderColor: CHART.axis }} />
mean {fmt(meanDaily)}
</span>
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<XAxis
dataKey="date"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
width={52}
/>
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
{/* Same axis, same unit — a mean line, not a second scale. */}
{meanDaily > 0 && (
<ReferenceLine y={meanDaily} stroke={CHART.axis} strokeDasharray="3 3" strokeWidth={1} />
)}
<Bar dataKey="amount" fill={CHART.accent} radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* ── The two phases, each on the axis that carries information ──
This pairing is the fix for the travel problem. Before departure
every row is a flight, a stay or a rail ticket, so `travel` is 99%
of it and category says nothing — merchant is what distinguishes
Agoda $4,490 from Air India $3,454. After departure travel drops to
a peer among dining, transport and groceries, and category is
finally worth charting. Same rows, two axes, chosen per phase. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start">
{hasPhases && committed_merchants.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<SectionHead
title="Booked ahead"
note={`Locked in before ${t.start_date ? fmtDate(t.start_date) : "departure"}. It is all flights and stays here, so the merchant is what tells them apart — not the category.`}
/>
<div className="space-y-3.5">
{committed_merchants.map((m) => (
<BarRow
key={m.merchant}
label={m.merchant || "Unknown"}
amount={Number(m.amount)}
count={m.count}
max={maxCommitted}
/>
))}
</div>
</div>
)}
{on_ground_categories.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<SectionHead
title={hasPhases ? "On the ground" : "By category"}
note={
hasPhases
? "Day-to-day spending once you arrived. With the bookings taken out, travel sits among its peers instead of swamping them."
: "This trip has no start date, so there is no departure to split on."
}
/>
<div className="space-y-3.5">
{on_ground_categories.map((c) => (
<BarRow
key={c.category}
label={formatCategory(c.category)}
amount={Number(c.amount)}
count={c.count}
max={maxOnGround}
sub={onGround > 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined}
/>
))}
</div>
</div>
)}
</div>
{/* Tag breakdown */}
{tag_breakdown.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-3">By Tag</h3>
<div className="flex flex-wrap gap-2">
{tag_breakdown.map((tag) => (
<div
key={tag.tag_id}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-zinc-800 bg-zinc-800/50"
>
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
<span className="text-sm font-medium">{tag.name}</span>
<span className="text-xs text-zinc-500">{tag.count} txns</span>
<span className="text-sm font-mono tabular-nums text-zinc-300">${Number(tag.amount).toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
{/* Participant splits */}
{participant_splits.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-medium">Participant Splits</h3>
<Link href="/shared" className="text-xs text-zinc-500 hover:text-zinc-300">
View in Shared
</Link>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{["Person", "This trip", "How it adds up", "Overall balance"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" || h === "How it adds up" ? "text-left" : "text-right"}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* One net figure per person — but a negative one is NOT a bill.
This is the distinction the page got wrong twice.
A payment is allocated to a scope as a lump sum, and the
grouped-payment allocation assigned each trip enough to clear
the payer's GROSS share. So when the other side of the trip is
netted off, a fully-paid trip goes negative by exactly the
amount the payer over-covered: Europe reads -$802.75 because
Sonu paid $8,004.04 against a net share of $7,201.30.
That surplus is not a debt the viewer must settle. It is
already carried in the overall balance — Sonu still owes
$5,313.38 overall — so "you owe them" was flatly wrong. Scope
nets sum to the overall figure; a negative here just means this
scope was over-covered and the excess sits in another.
So: negative WITH a payment into the scope is an overpayment,
and the overall column is where the actionable number lives.
Negative with NO payment is genuinely owed, because then the
viewer's share of the other person's spending simply exceeds
theirs. All three of today's negatives are the former.
Sign convention matches Shared: positive means they owe you. */}
{participant_splits.map((p) => {
const owedGross = Number(p.owed_gross);
const paidToMe = Number(p.paid_to_me);
const iOweGross = Number(p.i_owe_gross);
const paidByMe = Number(p.paid_by_me);
const net = Number(p.owed) - Number(p.i_owe);
const square = Math.abs(net) < 0.005;
const overpaid = net < -0.005 && paidToMe > 0.005;
const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
const parts = [
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null,
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
].filter(Boolean);
const overall = balances.find((b) => b.id === p.participant_id);
const overallNet = overall ? Number(overall.total_owed) : null;
return (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
<span className={square ? "text-zinc-500" : net > 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}>
${Math.abs(net).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square
? "all square"
: net > 0
? "still owed"
: overpaid
? "covered — they paid over"
: "you owe them"}
</span>
{unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {unconverted} unconverted
</span>
)}
</td>
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
{overpaid && (
<span className="block text-emerald-500/80 mt-0.5">
trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance,
not owing to them
</span>
)}
</td>
{/* The only figure anyone should act on. Without it a
over-covered scope reads as "pay them" when they are
still in debt to you overall. */}
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
{overallNet === null ? (
<span className="text-zinc-600 text-[11px] font-sans"></span>
) : (
<>
<span className={Math.abs(overallNet) < 0.005 ? "text-zinc-500" : overallNet > 0 ? "text-amber-400" : "text-blue-400"}>
${Math.abs(overallNet).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"}
</span>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{/* This note used to say a per-trip figure could not be computed,
because payments carried no trip attribution. Migration 0022 added
split_payments.trip_id, so it can and now does — the figures above
are net of payments scoped to this trip. What the note has to say
instead is which payments are NOT in them. */}
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
<strong className="font-medium text-zinc-400">This trip</strong> is their
share of what you paid, less what they paid you, less your share of what
they paid. A payment is allocated to a trip as a lump sum, so one that
covered someone&rsquo;s full share leaves this column negative by whatever
it over-covered that surplus is carried in{" "}
<strong className="font-medium text-zinc-400">Overall balance</strong>, and
is not money owed to them. Settle against the overall figure, never a
single trip.
<br />
Only payments <em className="not-italic text-zinc-400">scoped to this
trip</em> count here, so a debt settled by a payment left on the household
tab still reads as outstanding set the scope when recording one.
See <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
the full picture.
</p>
</div>
)}
{on_ground_categories.length === 0 && committed_merchants.length === 0 && daily_spend.length === 0 && (
<div className="text-center py-12 text-zinc-600">
<p className="text-sm">No transactions assigned to this trip yet.</p>
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
Go to Transactions to assign some
</Link>
</div>
)}
</div>
)}
{tab === "transactions" && (
<div>
{!txData?.data.length ? (
<div className="text-center py-12 text-zinc-600">
<p className="text-sm">No transactions assigned to this trip yet.</p>
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
Go to Transactions to assign some
</Link>
</div>
) : (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
{["Date", "Description", "Merchant", "Category", "Amount"].map((h) => (
<th
key={h}
className={`px-4 py-2.5 text-xs text-zinc-500 font-medium ${h === "Amount" ? "text-right" : "text-left"}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{txData.data.map((tx) => (
<tr key={tx.id} className="border-b border-zinc-800/40 last:border-0 hover:bg-zinc-900/40 transition-colors">
<td className="px-4 py-2.5 text-xs text-zinc-400 whitespace-nowrap">
{new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
</td>
<td className="px-4 py-2.5 max-w-xs truncate text-zinc-300">{tx.description}</td>
<td className="px-4 py-2.5 text-zinc-400 truncate">{tx.effective_merchant || "—"}</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs">{formatCategory(tx.effective_category)}</td>
<td className="px-4 py-2.5 text-right tabular-nums font-mono text-red-400">
${Number(tx.amount).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{editModal && trip && (
<CreateTripModal trip={trip} onClose={() => setEditModal(false)} />
)}
</div>
);
}