Files
finance-app/src/app/trips/[id]/page.tsx
T
siddharthd 48ec151c15 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.
2026-07-19 20:00:51 +10:00

368 lines
16 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,
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>
);
}