From 48ec151c15ce6ac408da16c24cc78a814c504bf9 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 19 Jul 2026 20:00:51 +1000 Subject: [PATCH] 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. --- prisma/schema.prisma | 15 + project-context.md | 111 ++++++ .../api/tags/[id]/convert-to-trip/route.ts | 37 ++ src/app/api/transactions/[id]/route.ts | 9 +- src/app/api/transactions/bulk/route.ts | 7 + src/app/api/transactions/route.ts | 1 + src/app/api/trips/[id]/analytics/route.ts | 15 + src/app/api/trips/[id]/route.ts | 30 ++ src/app/api/trips/[id]/transactions/route.ts | 15 + src/app/api/trips/route.ts | 20 + src/app/budget/page.tsx | 30 +- src/app/tags/page.tsx | 160 +++++++- src/app/transactions/page.tsx | 41 +- src/app/trips/[id]/page.tsx | 367 ++++++++++++++++++ src/app/trips/page.tsx | 153 ++++++++ src/components/create-trip-modal.tsx | 157 ++++++++ src/components/edit-transaction-modal.tsx | 22 ++ src/components/sidebar.tsx | 7 + src/lib/category-colors.ts | 34 ++ src/lib/hooks.ts | 96 ++++- src/lib/queries.ts | 234 +++++++++++ 21 files changed, 1506 insertions(+), 55 deletions(-) create mode 100644 project-context.md create mode 100644 src/app/api/tags/[id]/convert-to-trip/route.ts create mode 100644 src/app/api/trips/[id]/analytics/route.ts create mode 100644 src/app/api/trips/[id]/route.ts create mode 100644 src/app/api/trips/[id]/transactions/route.ts create mode 100644 src/app/api/trips/route.ts create mode 100644 src/app/trips/[id]/page.tsx create mode 100644 src/app/trips/page.tsx create mode 100644 src/components/create-trip-modal.tsx create mode 100644 src/lib/category-colors.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2a7b80..8a6feef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,19 @@ datasource db { provider = "postgresql" } +model trips { + id Int @id @default(autoincrement()) + owner_id Int + name String + description String? + start_date DateTime? @db.Date + end_date DateTime? @db.Date + color String @default("#6366f1") + archived Boolean @default(false) + created_at DateTime @default(now()) + overrides transaction_overrides[] +} + model transaction_overrides { id Int @id @default(autoincrement()) transaction_id Int @unique @@ -15,6 +28,8 @@ model transaction_overrides { notes String? my_share_percent Decimal? @db.Decimal(5, 2) updated_at DateTime @default(now()) @updatedAt + trip_id Int? + trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull) } model participants { diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..2db4976 --- /dev/null +++ b/project-context.md @@ -0,0 +1,111 @@ +# Personal Finance Tracker — Project Context + +## What Is This? + +A self-hosted personal finance tracker built from scratch. It automatically ingests bank statements, categorises transactions using AI, and provides a web UI for reviewing spending, managing shared expenses, and running analytics. + +--- + +## How It Works (High Level) + +### Automatic Statement Ingestion + +Bank statements (PDFs) are uploaded to a document management system (Paperless-NGX). An automation workflow (N8N) polls for new documents every 5 minutes and: + +1. Sends the PDF to Google Gemini (AI) for structured data extraction +2. Normalises the extracted data (merchant names, currencies, account numbers) +3. Inserts the statement summary and individual transactions into a PostgreSQL database +4. Creates a Google Calendar reminder for credit card payment due dates +5. For new/unknown bank accounts, requires a human approval before inserting + +--- + +## Core Features + +### Transactions View +- Full paginated list of all transactions across all bank accounts +- Filters: date range, category, bank, tags, transaction type, amount range, split status, free-text search +- Sortable columns including transaction date, amount, and import date +- Inline editing of category, merchant name, and notes +- Tagging system with user-defined coloured labels + +### Statements View +- One row per billing period per account +- Filters by bank, statement type, owner, year +- Click a statement to see only its transactions + +### Analytics / Insights +- Monthly spend breakdown by category (stacked bar chart) +- Category trend lines over time +- Pareto chart (which categories drive the most spend) +- Cumulative spend curve +- Savings rate over time +- Recurring charge detection (subscriptions/recurring merchants) +- Fees and interest audit (tracks what's been paid in bank fees and interest charges) +- Committed vs discretionary spend split + +### Merchant Profiles +- Per-merchant transaction history and net spend +- Scatter plot of spend over time +- Accounts for refunds/credits + +### Shared Expenses +- Split transactions between multiple people (by percentage) +- Tracks who owes what with a running balance +- Record cash settlements between participants +- Tag filter on shared view to track specific projects/events + +### Rules Engine +- Create saved rules that auto-apply categories, merchant names, tags, or splits to matching transactions +- Conditions: merchant name, description, category, bank, amount, transaction type +- Operators: contains, equals, starts with, greater/less than, not equals +- Bulk-apply all rules at once with full revert support (snapshot stored before each run) + +### Manual Transactions + Reconciliation +- Enter transactions manually (cash, receipts not on a statement) +- CSV import for bulk entry +- Reconcile manual transactions against statement transactions when the statement arrives — merges tags, splits, and notes onto the statement version + +### Multi-Owner Support +- Multiple people's accounts can be tracked in the one app +- Each statement/transaction is scoped to an owner +- The logged-in user only sees their own data + +--- + +## Technology + +- **Frontend**: Next.js (React), TypeScript, Tailwind CSS, Recharts for charts +- **Backend**: Next.js API routes with raw PostgreSQL queries (no ORM at query time) +- **Database**: PostgreSQL +- **AI extraction**: Google Gemini 2.5 Flash (PDF → structured JSON) +- **Automation**: N8N workflow orchestrates the ingestion pipeline +- **Auth**: Users are authenticated by the reverse proxy before reaching the app +- **Hosting**: Self-hosted Docker container on a home server + +--- + +## Data Structure (Summary) + +| Concept | Description | +|---------|-------------| +| **Statement** | One billing period for one bank account. Has summary totals (closing balance, fees, interest, credit limit, etc.) | +| **Transaction** | A line item. Has date, amount, merchant, category, transaction type (debit/credit/refund/fee/etc.) | +| **Override** | User correction to AI-extracted merchant name or category. Stored separately to preserve the original AI output | +| **Split** | A transaction shared with another person — records their percentage share and whether it's been settled | +| **Tag** | A free-form label applied to transactions (e.g. "Europe Trip 2026", "Home Reno") | +| **Rule** | A saved condition→action pair applied in bulk to auto-categorise/tag/split transactions | +| **Participant** | A person (account owner or expense-sharing partner) | +| **Split Payment** | A recorded cash settlement between two participants | + +### Category Taxonomy + +Fixed set used by AI and overridable by the user: +`groceries`, `dining`, `transport`, `fuel`, `shopping`, `utilities`, `entertainment`, `travel`, `health`, `insurance`, `subscriptions`, `cash_advance`, `government`, `education`, `rent`, `home_goods`, `home_maintenance`, `transfers`, `income`, `investment`, `personal_care`, `pets`, `gifts`, `charity`, `other` + +--- + +## Known Limitations / Planned Work + +- **Payment provider conflation**: Transactions processed through PayPal, Afterpay, Zip etc. sometimes show the payment provider as the merchant rather than the actual store. Plan is to extract `payment_provider` as a separate field so the real merchant is preserved. +- **Budgets**: The database has a budget table but it's not currently surfaced in the UI (the analytics/insights views replaced it for now). diff --git a/src/app/api/tags/[id]/convert-to-trip/route.ts b/src/app/api/tags/[id]/convert-to-trip/route.ts new file mode 100644 index 0000000..22e5501 --- /dev/null +++ b/src/app/api/tags/[id]/convert-to-trip/route.ts @@ -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 }); +} diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts index b6947bf..a288cd2 100644 --- a/src/app/api/transactions/[id]/route.ts +++ b/src/app/api/transactions/[id]/route.ts @@ -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, }, }); diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index cef9cd3..8b15a63 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -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 }); } diff --git a/src/app/api/transactions/route.ts b/src/app/api/transactions/route.ts index 5045a06..310acf9 100644 --- a/src/app/api/transactions/route.ts +++ b/src/app/api/transactions/route.ts @@ -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); diff --git a/src/app/api/trips/[id]/analytics/route.ts b/src/app/api/trips/[id]/analytics/route.ts new file mode 100644 index 0000000..2c45082 --- /dev/null +++ b/src/app/api/trips/[id]/analytics/route.ts @@ -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 }); + } +} diff --git a/src/app/api/trips/[id]/route.ts b/src/app/api/trips/[id]/route.ts new file mode 100644 index 0000000..62a04de --- /dev/null +++ b/src/app/api/trips/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/trips/[id]/transactions/route.ts b/src/app/api/trips/[id]/transactions/route.ts new file mode 100644 index 0000000..37163ce --- /dev/null +++ b/src/app/api/trips/[id]/transactions/route.ts @@ -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 }); +} diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts new file mode 100644 index 0000000..06f9c2a --- /dev/null +++ b/src/app/api/trips/route.ts @@ -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 }); +} diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 430cdc2..20818a5 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -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 = { - 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 ──────────────────────────────────────────────────────────────── diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx index 24aca14..895bcb8 100644 --- a/src/app/tags/page.tsx +++ b/src/app/tags/page.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+
+ +

Convert "{tag.name}" to Trip

+
+

+ Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it. +

+
+ +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+

Dates are optional — you can set them later from the trip page.

+ + +
+ +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} + 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) => (
- + {tag.name} {tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
- +
+ + +
))} )} + + {convertTag && ( + setConvertTag(null)} /> + )} ); } diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index a36791c..0246e45 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -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([]); @@ -506,6 +507,7 @@ function TransactionsContent() { const [selected, setSelected] = useState>(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[0]["prefill"]; title?: string } | null>(null); const [editModal, setEditModal] = useState(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() { + {/* Bulk action bar */} @@ -748,6 +763,30 @@ function TransactionsContent() { > Tag + + + + + + {/* Stat cards */} +
+ + + + +
+ + {/* Tab bar */} +
+ {(["overview", "transactions"] as const).map((tabName) => ( + + ))} +
+ + {tab === "overview" && ( +
+ {/* Daily spend */} + {daily_spend.length > 0 && ( +
+

Daily Spend

+ + + new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} + interval="preserveStartEnd" + /> + `$${v}`} + width={52} + /> + } cursor={{ fill: "#27272a" }} /> + + + +
+ )} + +
+ {/* Category breakdown */} + {category_breakdown.length > 0 && ( +
+

By Category

+ + + `$${v}`} /> + + } cursor={{ fill: "#27272a" }} /> + + {category_breakdown.map((entry) => ( + + ))} + + + +
+ )} + + {/* Top merchants */} + {top_merchants.length > 0 && ( +
+

Top Merchants

+
+ {top_merchants.map((m, i) => ( +
+ {i + 1} +
+
+ {m.merchant || "Unknown"} + ${Number(m.amount).toFixed(2)} +
+
+
+
+
+
+ ))} +
+
+ )} +
+ + {/* Tag breakdown */} + {tag_breakdown.length > 0 && ( +
+

By Tag

+
+ {tag_breakdown.map((tag) => ( +
+ + {tag.name} + {tag.count} txns + ${Number(tag.amount).toFixed(2)} +
+ ))} +
+
+ )} + + {/* Participant splits */} + {participant_splits.length > 0 && ( +
+
+

Participant Splits

+ + View in Shared → + +
+ + + + {["Person", "Total Owed", "Settled", "Unsettled"].map((h) => ( + + ))} + + + + {participant_splits.map((p) => ( + + + + + + + ))} + +
+ {h} +
{p.name}${Number(p.owed).toFixed(2)}${Number(p.settled).toFixed(2)} 0 ? "text-amber-400" : "text-zinc-600"}`}> + ${Number(p.unsettled).toFixed(2)} +
+
+ )} + + {category_breakdown.length === 0 && daily_spend.length === 0 && ( +
+

No transactions assigned to this trip yet.

+ + Go to Transactions to assign some → + +
+ )} +
+ )} + + {tab === "transactions" && ( +
+ {!txData?.data.length ? ( +
+

No transactions assigned to this trip yet.

+ + Go to Transactions to assign some → + +
+ ) : ( +
+ + + + {["Date", "Description", "Merchant", "Category", "Amount"].map((h) => ( + + ))} + + + + {txData.data.map((tx) => ( + + + + + + + + ))} + +
+ {h} +
+ {new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} + {tx.description}{tx.effective_merchant || "—"}{formatCategory(tx.effective_category)} + ${Number(tx.amount).toFixed(2)} +
+
+ )} +
+ )} + + {editModal && trip && ( + setEditModal(false)} /> + )} +
+ ); +} diff --git a/src/app/trips/page.tsx b/src/app/trips/page.tsx new file mode 100644 index 0000000..e1e8d88 --- /dev/null +++ b/src/app/trips/page.tsx @@ -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 ( +
+
+ +

{trip.name}

+ {trip.description && ( +

{trip.description}

+ )} +

{dateRange}

+
+ ${Number(trip.total_spend).toFixed(2)} + {trip.transaction_count} transactions +
+ +
+ + +
+
+ ); +} + +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 ( +
+
+
+

Trips

+

Group and analyse expenses by trip

+
+ +
+ + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : active.length === 0 ? ( +
+ + + + +

No trips yet

+

Create a trip to group and analyse expenses — holidays, events, work travel.

+ +
+ ) : ( +
+ {active.map((trip) => ( + setModal({ trip })} + onDelete={() => { + if (confirm(`Delete "${trip.name}"? This will unlink all transactions from this trip.`)) { + deleteTrip.mutate(trip.id); + } + }} + /> + ))} +
+ )} + + {archived.length > 0 && ( +
+ + + + + Archived ({archived.length}) + +
+ {archived.map((trip) => ( +
+
+ +

{trip.name}

+

${Number(trip.total_spend).toFixed(2)} · {trip.transaction_count} transactions

+ +
+ +
+
+ ))} +
+
+ )} + + {modal !== null && ( + setModal(null)} /> + )} +
+ ); +} diff --git a/src/components/create-trip-modal.tsx b/src/components/create-trip-modal.tsx new file mode 100644 index 0000000..28e8d57 --- /dev/null +++ b/src/components/create-trip-modal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { useCreateTrip, useUpdateTrip } from "@/lib/hooks"; +import type { TripRow } from "@/lib/hooks"; + +export function CreateTripModal({ + trip, + onClose, +}: { + trip?: TripRow; + onClose: () => void; +}) { + const isEdit = !!trip; + const [name, setName] = useState(trip?.name ?? ""); + const [description, setDescription] = useState(trip?.description ?? ""); + const [startDate, setStartDate] = useState(trip?.start_date?.slice(0, 10) ?? ""); + const [endDate, setEndDate] = useState(trip?.end_date?.slice(0, 10) ?? ""); + const [color, setColor] = useState(trip?.color ?? "#6366f1"); + const [archived, setArchived] = useState(trip?.archived ?? false); + const [error, setError] = useState(""); + + const createTrip = useCreateTrip(); + const updateTrip = useUpdateTrip(); + const isPending = createTrip.isPending || updateTrip.isPending; + + async function handleSave() { + setError(""); + if (!name.trim()) { setError("Name is required"); return; } + try { + if (isEdit) { + await updateTrip.mutateAsync({ + id: trip!.id, + name: name.trim(), + description: description || null, + start_date: startDate || null, + end_date: endDate || null, + color, + archived, + }); + } else { + await createTrip.mutateAsync({ + name: name.trim(), + description: description || null, + start_date: startDate || null, + end_date: endDate || null, + color, + archived, + }); + } + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } + } + + return ( +
+
e.stopPropagation()} + > +
+

{isEdit ? "Edit Trip" : "New Trip"}

+
+ +
+
+ + setName(e.target.value)} + placeholder="e.g. Europe 2026" + 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" + /> +
+ +
+ +