feat(trips): trip tracking with analytics, tag conversion, and transaction assignment

Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
This commit is contained in:
2026-07-19 20:00:51 +10:00
parent b706973fb5
commit 48ec151c15
21 changed files with 1506 additions and 55 deletions
+367
View File
@@ -0,0 +1,367 @@
"use client";
import { use, useState } from "react";
import Link from "next/link";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS, 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 StatCard({
label,
value,
sub,
color,
}: {
label: string;
value: string;
sub?: string;
color: string;
}) {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
<p className="text-xs text-zinc-500 mb-1">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</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>
);
}
function CategoryTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string }; value: number }[] }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1">{formatCategory(payload[0].payload.category)}</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);
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
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_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
const t = analytics.trip;
const maxMerchant = top_merchants[0]?.amount ?? 1;
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>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all transactions" color={t.color} />
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
</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">
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
<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" }} />
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Category breakdown */}
{category_breakdown.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">By Category</h3>
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
<BarChart
data={category_breakdown}
layout="vertical"
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
>
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
<YAxis
type="category"
dataKey="category"
tick={{ fill: "#a1a1aa", fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
width={98}
/>
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
{category_breakdown.map((entry) => (
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Top merchants */}
{top_merchants.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
<div className="space-y-3">
{top_merchants.map((m, i) => (
<div key={m.merchant} className="flex items-center gap-3">
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm truncate">{m.merchant || "Unknown"}</span>
<span className="text-sm font-mono tabular-nums ml-2 flex-shrink-0">${Number(m.amount).toFixed(2)}</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(m.amount / maxMerchant) * 100}%`,
backgroundColor: t.color,
opacity: 0.7,
}}
/>
</div>
</div>
</div>
))}
</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", "Total Owed", "Settled", "Unsettled"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{participant_splits.map((p) => (
<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">${Number(p.owed).toFixed(2)}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono text-emerald-500">${Number(p.settled).toFixed(2)}</td>
<td className={`px-5 py-3 text-right tabular-nums font-mono ${p.unsettled > 0 ? "text-amber-400" : "text-zinc-600"}`}>
${Number(p.unsettled).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{category_breakdown.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>
);
}