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
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { createTrip, assignTransactionsToTrip, getTagTransactionIds } from "@/lib/queries";
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const tagId = Number(id);
const { start_date, end_date } = await req.json().catch(() => ({}));
// Fetch the tag
const tags = await queryRaw<{ id: number; name: string; color: string }>(
`SELECT id, name, color FROM tags WHERE id = $1`,
[tagId]
);
if (!tags[0]) return NextResponse.json({ error: "Tag not found" }, { status: 404 });
const tag = tags[0];
// Create trip from tag metadata
const trip = await createTrip(user.id, {
name: tag.name,
color: tag.color,
start_date: start_date ?? null,
end_date: end_date ?? null,
});
// Assign all transactions with this tag to the new trip
const transactionIds = await getTagTransactionIds(tagId);
if (transactionIds.length > 0) {
await assignTransactionsToTrip(trip.id, transactionIds);
}
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 });
}
+6 -3
View File
@@ -23,7 +23,7 @@ export async function PATCH(
const transactionId = Number(id);
const body = await req.json();
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as {
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id } = body as {
category?: string;
merchant_normalized?: string;
notes?: string;
@@ -32,6 +32,7 @@ export async function PATCH(
description?: string;
amount?: number;
transaction_date?: string;
trip_id?: number | null;
};
if (my_share_percent !== undefined && my_share_percent !== null) {
@@ -72,8 +73,8 @@ export async function PATCH(
);
}
// category/merchant/notes/my_share_percent go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined;
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
if (!hasOverride) {
return NextResponse.json({ ok: true });
}
@@ -83,6 +84,7 @@ export async function PATCH(
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
if (notes !== undefined) data.notes = notes;
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent;
if (trip_id !== undefined) data.trip_id = trip_id;
const override = await prisma.transaction_overrides.upsert({
where: { transaction_id: transactionId },
@@ -93,6 +95,7 @@ export async function PATCH(
merchant_normalized: merchant_normalized || null,
notes: notes || null,
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
trip_id: trip_id ?? null,
},
});
+7
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip } from "@/lib/queries";
export async function POST(req: NextRequest) {
const body = await req.json();
@@ -77,5 +78,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length });
}
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids);
return NextResponse.json({ updated: ids.length });
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
+1
View File
@@ -25,6 +25,7 @@ export async function GET(req: NextRequest) {
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined,
trip_id: sp.get("trip_id") || undefined,
});
return NextResponse.json(result);
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTripAnalytics } from "@/lib/queries";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
try {
const analytics = await getTripAnalytics(Number(id), user.id);
return NextResponse.json(analytics);
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTripById, updateTrip, deleteTrip } from "@/lib/queries";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const trip = await getTripById(Number(id), user.id);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(trip);
}
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const body = await req.json();
const trip = await updateTrip(Number(id), user.id, body);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(trip);
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
await deleteTrip(Number(id), user.id);
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { assignTransactionsToTrip } from "@/lib/queries";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const { transactionIds } = await req.json() as { transactionIds: number[] };
if (!Array.isArray(transactionIds) || !transactionIds.length) {
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
}
await assignTransactionsToTrip(Number(id), transactionIds);
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTrips, createTrip } from "@/lib/queries";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const trips = await getTrips(user.id);
return NextResponse.json(trips);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json();
const { name, description, start_date, end_date, color } = body;
if (!name?.trim()) return NextResponse.json({ error: "name is required" }, { status: 400 });
const trip = await createTrip(user.id, { name: name.trim(), description, start_date, end_date, color });
return NextResponse.json(trip, { status: 201 });
}
+1 -29
View File
@@ -17,6 +17,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
function currentMonthStr(): string {
const now = new Date();
@@ -48,35 +49,6 @@ function deltaColor(n: number): string {
return "";
}
const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 };
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#22c55e",
dining: "#f97316",
transport: "#06b6d4",
fuel: "#eab308",
shopping: "#ec4899",
utilities: "#8b5cf6",
entertainment: "#f43f5e",
travel: "#0ea5e9",
health: "#10b981",
insurance: "#64748b",
subscriptions: "#a78bfa",
cash_advance: "#dc2626",
government: "#78716c",
education: "#3b82f6",
rent: "#d97706",
transfers: "#6b7280",
income: "#34d399",
investment: "#818cf8",
personal_care: "#fb7185",
pets: "#86efac",
gifts: "#fcd34d",
charity: "#a3e635",
home_goods: "#67e8f9",
home_maintenance: "#c084fc",
other: "#71717a",
};
// ─── Tooltips ────────────────────────────────────────────────────────────────
+139 -21
View File
@@ -1,21 +1,128 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
const PRESET_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#3b82f6", // blue
"#6b7280", // gray
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
];
function ConvertModal({
tag,
onClose,
}: {
tag: { id: number; name: string; color: string; transaction_count: number };
onClose: () => void;
}) {
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [deleteTag, setDeleteTag] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const qc = useQueryClient();
async function handleConvert() {
setSaving(true);
setError("");
try {
const res = await fetch(`/api/tags/${tag.id}/convert-to-trip`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start_date: startDate || null, end_date: endDate || null }),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed");
const { trip } = await res.json();
if (deleteTag) {
await fetch(`/api/tags/${tag.id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["tags"] });
}
qc.invalidateQueries({ queryKey: ["trips"] });
router.push(`/trips/${trip.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to convert");
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
</div>
<p className="text-xs text-zinc-500 mt-1">
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
</p>
</div>
<div className="px-6 py-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
/>
</div>
</div>
<p className="text-xs text-zinc-600">Dates are optional you can set them later from the trip page.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={deleteTag}
onChange={(e) => setDeleteTag(e.target.checked)}
className="accent-indigo-500"
/>
<span className="text-sm text-zinc-400">Delete this tag after converting</span>
</label>
</div>
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2 items-center">
{error && <p className="text-red-400 text-xs mr-auto">{error}</p>}
<div className="flex gap-2 ml-auto">
<button
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
<button
onClick={handleConvert}
disabled={saving}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Converting…" : "Convert to Trip"}
</button>
</div>
</div>
</div>
</div>
);
}
export default function TagsPage() {
const { data: tags, isLoading } = useTags();
const createTag = useCreateTag();
@@ -24,6 +131,7 @@ export default function TagsPage() {
const [name, setName] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [error, setError] = useState("");
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
const handleCreate = async () => {
if (!name.trim()) return;
@@ -83,28 +191,38 @@ export default function TagsPage() {
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg group"
>
<div className="flex items-center gap-3">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="w-3 h-3 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.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
</span>
</div>
<button
onClick={() => deleteTag.mutate(tag.id)}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
>
Delete
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setConvertTag(tag)}
className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
title="Convert to Trip"
>
Trip
</button>
<button
onClick={() => deleteTag.mutate(tag.id)}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
{convertTag && (
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
)}
</div>
);
}
+40 -1
View File
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
@@ -477,6 +477,7 @@ function TransactionsContent() {
amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined,
has_split: "" as string,
trip_id: "" as string,
});
const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
@@ -506,6 +507,7 @@ function TransactionsContent() {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [bulkTripId, setBulkTripId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
@@ -524,6 +526,8 @@ function TransactionsContent() {
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
const { data: trips = [] } = useTrips();
const assignToTrip = useAssignTransactionsToTrip();
const toggleSelect = useCallback((id: number) => {
setSelected((prev) => {
@@ -687,6 +691,17 @@ function TransactionsContent() {
<option value="yes">Split only</option>
<option value="no">Unsplit only</option>
</select>
<select
value={filters.trip_id}
onChange={(e) => setFilters((f) => ({ ...f, trip_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
>
<option value="">All Trips</option>
<option value="unassigned">No Trip</option>
{trips.map((t) => (
<option key={t.id} value={String(t.id)}>{t.name}</option>
))}
</select>
</div>
{/* Bulk action bar */}
@@ -748,6 +763,30 @@ function TransactionsContent() {
>
Tag
</button>
<select
value={bulkTripId}
onChange={(e) => setBulkTripId(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Assign trip</option>
<option value="remove">Remove from trip</option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={String(t.id)}>{t.name}</option>
))}
</select>
<button
disabled={!bulkTripId || assignToTrip.isPending}
onClick={() => {
const tripId = bulkTripId === "remove" ? null : Number(bulkTripId);
assignToTrip.mutate(
{ tripId, transactionIds: Array.from(selected) },
{ onSuccess: () => { setSelected(new Set()); setBulkTripId(""); } }
);
}}
className="px-3 py-1 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 rounded text-sm"
>
{bulkTripId === "remove" ? "Remove" : "Assign"}
</button>
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
+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>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useTrips, useDeleteTrip } from "@/lib/hooks";
import type { TripRow } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
function fmtDate(d: string | null) {
if (!d) return null;
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function TripCard({ trip, onEdit, onDelete }: { trip: TripRow; onEdit: () => void; onDelete: () => void }) {
const dateRange = trip.start_date && trip.end_date
? `${fmtDate(trip.start_date)} ${fmtDate(trip.end_date)}`
: trip.start_date
? `From ${fmtDate(trip.start_date)}`
: "No dates set";
return (
<div className="relative bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden hover:border-zinc-600 transition-colors group">
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl" style={{ backgroundColor: trip.color }} />
<Link href={`/trips/${trip.id}`} className="block p-5 pl-6">
<p className="font-semibold text-base truncate">{trip.name}</p>
{trip.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate">{trip.description}</p>
)}
<p className="text-xs text-zinc-600 mt-1">{dateRange}</p>
<div className="mt-4 flex items-baseline gap-3">
<span className="text-2xl font-semibold tabular-nums">${Number(trip.total_spend).toFixed(2)}</span>
<span className="text-xs text-zinc-500">{trip.transaction_count} transactions</span>
</div>
</Link>
<div className="px-5 pb-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onEdit}
className="text-xs text-zinc-400 hover:text-white px-2 py-1 rounded hover:bg-zinc-800 transition-colors"
>
Edit
</button>
<button
onClick={onDelete}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1 rounded hover:bg-zinc-800 transition-colors"
>
Delete
</button>
</div>
</div>
);
}
export default function TripsPage() {
const { data: trips = [], isLoading } = useTrips();
const deleteTrip = useDeleteTrip();
const [modal, setModal] = useState<{ trip?: TripRow } | null>(null);
const active = trips.filter((t) => !t.archived);
const archived = trips.filter((t) => t.archived);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Trips</h2>
<p className="text-sm text-zinc-500 mt-0.5">Group and analyse expenses by trip</p>
</div>
<button
onClick={() => setModal({})}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
>
+ New Trip
</button>
</div>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 animate-pulse">
<div className="h-4 bg-zinc-800 rounded w-2/3 mb-3" />
<div className="h-3 bg-zinc-800 rounded w-1/3 mb-4" />
<div className="h-7 bg-zinc-800 rounded w-1/2" />
</div>
))}
</div>
) : active.length === 0 ? (
<div className="text-center py-20 text-zinc-600">
<svg className="w-12 h-12 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-lg font-medium mb-1">No trips yet</p>
<p className="text-sm">Create a trip to group and analyse expenses holidays, events, work travel.</p>
<button
onClick={() => setModal({})}
className="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
>
Create your first trip
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{active.map((trip) => (
<TripCard
key={trip.id}
trip={trip}
onEdit={() => setModal({ trip })}
onDelete={() => {
if (confirm(`Delete "${trip.name}"? This will unlink all transactions from this trip.`)) {
deleteTrip.mutate(trip.id);
}
}}
/>
))}
</div>
)}
{archived.length > 0 && (
<details className="group/archived">
<summary className="text-sm text-zinc-500 cursor-pointer hover:text-zinc-300 list-none flex items-center gap-2 select-none">
<svg className="w-3 h-3 transition-transform group-open/archived:rotate-90" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 4.707a1 1 0 011.414 0L14 10l-5.293 5.293a1 1 0 01-1.414-1.414L11.586 10 6.586 5a1 1 0 010-1.293z" clipRule="evenodd" />
</svg>
Archived ({archived.length})
</summary>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
{archived.map((trip) => (
<div key={trip.id} className="relative bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden opacity-60 hover:opacity-100 transition-opacity group">
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl" style={{ backgroundColor: trip.color }} />
<Link href={`/trips/${trip.id}`} className="block p-5 pl-6">
<p className="font-medium truncate">{trip.name}</p>
<p className="text-xs text-zinc-600 mt-1">${Number(trip.total_spend).toFixed(2)} · {trip.transaction_count} transactions</p>
</Link>
<div className="px-5 pb-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setModal({ trip })}
className="text-xs text-zinc-400 hover:text-white px-2 py-1 rounded hover:bg-zinc-800"
>
Edit
</button>
</div>
</div>
))}
</div>
</details>
)}
{modal !== null && (
<CreateTripModal trip={modal.trip} onClose={() => setModal(null)} />
)}
</div>
);
}