ci / lint-test (push) Successful in 48s
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.
613 lines
30 KiB
TypeScript
613 lines
30 KiB
TypeScript
"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’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>
|
||
);
|
||
}
|