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
+15
View File
@@ -7,6 +7,19 @@ datasource db {
provider = "postgresql" 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 { model transaction_overrides {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
transaction_id Int @unique transaction_id Int @unique
@@ -15,6 +28,8 @@ model transaction_overrides {
notes String? notes String?
my_share_percent Decimal? @db.Decimal(5, 2) my_share_percent Decimal? @db.Decimal(5, 2)
updated_at DateTime @default(now()) @updatedAt updated_at DateTime @default(now()) @updatedAt
trip_id Int?
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
} }
model participants { model participants {
+111
View File
@@ -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).
@@ -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 transactionId = Number(id);
const body = await req.json(); 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; category?: string;
merchant_normalized?: string; merchant_normalized?: string;
notes?: string; notes?: string;
@@ -32,6 +32,7 @@ export async function PATCH(
description?: string; description?: string;
amount?: number; amount?: number;
transaction_date?: string; transaction_date?: string;
trip_id?: number | null;
}; };
if (my_share_percent !== undefined && my_share_percent !== 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 // 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; const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
if (!hasOverride) { if (!hasOverride) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -83,6 +84,7 @@ export async function PATCH(
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized; if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
if (notes !== undefined) data.notes = notes; if (notes !== undefined) data.notes = notes;
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent; 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({ const override = await prisma.transaction_overrides.upsert({
where: { transaction_id: transactionId }, where: { transaction_id: transactionId },
@@ -93,6 +95,7 @@ export async function PATCH(
merchant_normalized: merchant_normalized || null, merchant_normalized: merchant_normalized || null,
notes: notes || null, notes: notes || null,
my_share_percent: my_share_percent != null ? String(my_share_percent) : 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 { NextRequest, NextResponse } from "next/server";
import { prisma, queryRaw } from "@/lib/db"; import { prisma, queryRaw } from "@/lib/db";
import { assignTransactionsToTrip } from "@/lib/queries";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const body = await req.json(); const body = await req.json();
@@ -77,5 +78,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ updated: ids.length }); 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 }); 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_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined, amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined, has_split: sp.get("has_split") || undefined,
trip_id: sp.get("trip_id") || undefined,
}); });
return NextResponse.json(result); 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 { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories"; import { formatCategory, CATEGORIES } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
function currentMonthStr(): string { function currentMonthStr(): string {
const now = new Date(); const now = new Date();
@@ -48,35 +49,6 @@ function deltaColor(n: number): string {
return ""; 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 ──────────────────────────────────────────────────────────────── // ─── Tooltips ────────────────────────────────────────────────────────────────
+139 -21
View File
@@ -1,21 +1,128 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks"; import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#6366f1", // indigo "#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#8b5cf6", // violet "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
"#ec4899", // pink
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#3b82f6", // blue
"#6b7280", // gray
]; ];
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() { export default function TagsPage() {
const { data: tags, isLoading } = useTags(); const { data: tags, isLoading } = useTags();
const createTag = useCreateTag(); const createTag = useCreateTag();
@@ -24,6 +131,7 @@ export default function TagsPage() {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]); const [color, setColor] = useState(PRESET_COLORS[0]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
const handleCreate = async () => { const handleCreate = async () => {
if (!name.trim()) return; if (!name.trim()) return;
@@ -83,28 +191,38 @@ export default function TagsPage() {
{tags.map((tag) => ( {tags.map((tag) => (
<div <div
key={tag.id} 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"> <div className="flex items-center gap-3">
<span <span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
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-sm font-medium">{tag.name}</span>
<span className="text-xs text-zinc-500"> <span className="text-xs text-zinc-500">
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""} {tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
</span> </span>
</div> </div>
<button <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
onClick={() => deleteTag.mutate(tag.id)} <button
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800" onClick={() => setConvertTag(tag)}
> className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
Delete title="Convert to Trip"
</button> >
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>
))} ))}
</div> </div>
)} )}
{convertTag && (
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
)}
</div> </div>
); );
} }
+40 -1
View File
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef, useEffect, Suspense } from "react"; import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation"; 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 { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal"; import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker"; import { TagPicker } from "@/components/tag-picker";
@@ -477,6 +477,7 @@ function TransactionsContent() {
amount_min: undefined as number | undefined, amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined, amount_max: undefined as number | undefined,
has_split: "" as string, has_split: "" as string,
trip_id: "" as string,
}); });
const [queryInput, setQueryInput] = useState(""); const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]); const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
@@ -506,6 +507,7 @@ function TransactionsContent() {
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState(""); const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = 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 [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 [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | 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 { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
const updateTxn = useUpdateTransaction(); const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction(); const bulkAction = useBulkAction();
const { data: trips = [] } = useTrips();
const assignToTrip = useAssignTransactionsToTrip();
const toggleSelect = useCallback((id: number) => { const toggleSelect = useCallback((id: number) => {
setSelected((prev) => { setSelected((prev) => {
@@ -687,6 +691,17 @@ function TransactionsContent() {
<option value="yes">Split only</option> <option value="yes">Split only</option>
<option value="no">Unsplit only</option> <option value="no">Unsplit only</option>
</select> </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> </div>
{/* Bulk action bar */} {/* Bulk action bar */}
@@ -748,6 +763,30 @@ function TransactionsContent() {
> >
Tag Tag
</button> </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 <button
onClick={() => setSelected(new Set())} onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm" 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>
);
}
+157
View File
@@ -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 (
<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-md mx-4 shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<h3 className="font-semibold text-sm text-zinc-300">{isEdit ? "Edit Trip" : "New Trip"}</h3>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-xs text-zinc-500 mb-1">Name *</label>
<input
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Optional notes about this trip"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none focus:outline-none focus:border-zinc-500"
/>
</div>
<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>
<div className="flex items-center gap-4">
<div>
<label className="block text-xs text-zinc-500 mb-1">Color</label>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-9 w-16 rounded border border-zinc-700 bg-zinc-800 cursor-pointer"
/>
</div>
{isEdit && (
<label className="flex items-center gap-2 text-sm text-zinc-400 mt-4 cursor-pointer select-none">
<input
type="checkbox"
checked={archived}
onChange={(e) => setArchived(e.target.checked)}
className="accent-indigo-500"
/>
Archived
</label>
)}
</div>
</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
type="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
type="button"
onClick={handleSave}
disabled={isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{isPending ? "Saving…" : isEdit ? "Save Changes" : "Create Trip"}
</button>
</div>
</div>
</div>
</div>
);
}
+22
View File
@@ -7,6 +7,7 @@ import {
useAddTransactionTag, useAddTransactionTag,
useRemoveTransactionTag, useRemoveTransactionTag,
useTransactionSplits, useTransactionSplits,
useTrips,
} from "@/lib/hooks"; } from "@/lib/hooks";
import { SplitModal } from "./split-modal"; import { SplitModal } from "./split-modal";
import { CATEGORIES, formatCategory } from "@/lib/categories"; import { CATEGORIES, formatCategory } from "@/lib/categories";
@@ -93,6 +94,7 @@ export function EditTransactionModal({
}) { }) {
const isManual = !transaction.statement_id; const isManual = !transaction.statement_id;
const updateTxn = useUpdateTransaction(); const updateTxn = useUpdateTransaction();
const { data: trips = [] } = useTrips();
// Editable override fields // Editable override fields
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? ""); const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
@@ -105,6 +107,8 @@ export function EditTransactionModal({
const [description, setDescription] = useState(transaction.description); const [description, setDescription] = useState(transaction.description);
const [amount, setAmount] = useState(String(transaction.amount)); const [amount, setAmount] = useState(String(transaction.amount));
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
// Splits — live via hook so they refresh after SplitModal saves // Splits — live via hook so they refresh after SplitModal saves
const { data: liveSplits = [] } = useTransactionSplits(transaction.id); const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
@@ -136,6 +140,9 @@ export function EditTransactionModal({
patch.amount = parseFloat(amount); patch.amount = parseFloat(amount);
} }
if (tripId !== (transaction.trip_id ?? null))
patch.trip_id = tripId;
await updateTxn.mutateAsync(patch); await updateTxn.mutateAsync(patch);
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -255,6 +262,21 @@ export function EditTransactionModal({
</div> </div>
</div> </div>
{/* Trip */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Trip</label>
<select
value={tripId ?? ""}
onChange={(e) => setTripId(e.target.value ? Number(e.target.value) : null)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> No Trip </option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
{/* Tags */} {/* Tags */}
<div> <div>
<p className="text-xs text-zinc-500 mb-1.5">Tags</p> <p className="text-xs text-zinc-500 mb-1.5">Tags</p>
+7
View File
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/transactions", label: "Transactions", icon: "receipt" }, { href: "/transactions", label: "Transactions", icon: "receipt" },
{ href: "/statements", label: "Statements", icon: "file-text" }, { href: "/statements", label: "Statements", icon: "file-text" },
{ href: "/trips", label: "Trips", icon: "map-pin" },
{ href: "/shared", label: "Shared", icon: "users" }, { href: "/shared", label: "Shared", icon: "users" },
{ href: "/budget", label: "Analytics", icon: "bar-chart" }, { href: "/budget", label: "Analytics", icon: "bar-chart" },
{ href: "/insights", label: "Insights", icon: "lightbulb" }, { href: "/insights", label: "Insights", icon: "lightbulb" },
@@ -27,6 +28,12 @@ const ICONS: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
), ),
"map-pin": (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
users: ( users: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
+34
View File
@@ -0,0 +1,34 @@
export 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",
};
export const TOOLTIP_STYLE = {
background: "#18181b",
border: "1px solid #3f3f46",
borderRadius: 8,
fontSize: 12,
};
+95 -1
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { TransactionRow, StatementRow, TagRow } from "./queries"; import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries";
export type { TripRow, TripAnalytics };
import type { CurrentUser } from "./auth"; import type { CurrentUser } from "./auth";
interface TransactionsResponse { interface TransactionsResponse {
@@ -27,6 +28,7 @@ interface TransactionFilters {
amount_min?: number; amount_min?: number;
amount_max?: number; amount_max?: number;
has_split?: string; has_split?: string;
trip_id?: string;
} }
function buildParams(filters: TransactionFilters): string { function buildParams(filters: TransactionFilters): string {
@@ -137,6 +139,7 @@ export function useUpdateTransaction() {
description?: string; description?: string;
amount?: number; amount?: number;
transaction_date?: string; transaction_date?: string;
trip_id?: number | null;
}) => { }) => {
const res = await fetch(`/api/transactions/${id}`, { const res = await fetch(`/api/transactions/${id}`, {
method: "PATCH", method: "PATCH",
@@ -801,3 +804,94 @@ export function useMerchantTransactions(merchant: string | null) {
enabled: !!merchant, enabled: !!merchant,
}); });
} }
// ─── Trips ───────────────────────────────────────────────────────────────────
export function useTrips() {
return useQuery<TripRow[]>({
queryKey: ["trips"],
queryFn: async () => (await fetch("/api/trips")).json(),
});
}
export function useTrip(id: number) {
return useQuery<TripRow>({
queryKey: ["trip", id],
queryFn: async () => (await fetch(`/api/trips/${id}`)).json(),
enabled: id > 0,
});
}
export function useTripAnalytics(id: number) {
return useQuery<TripAnalytics>({
queryKey: ["trip-analytics", id],
queryFn: async () => (await fetch(`/api/trips/${id}/analytics`)).json(),
enabled: id > 0,
});
}
export function useCreateTrip() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<TripRow, "id" | "owner_id" | "created_at" | "total_spend" | "transaction_count">) => {
const res = await fetch("/api/trips", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed");
return res.json() as Promise<TripRow>;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
});
}
export function useUpdateTrip() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: Partial<TripRow> & { id: number }) => {
const res = await fetch(`/api/trips/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed");
return res.json() as Promise<TripRow>;
},
onSuccess: (_d, { id }) => {
qc.invalidateQueries({ queryKey: ["trips"] });
qc.invalidateQueries({ queryKey: ["trip", id] });
qc.invalidateQueries({ queryKey: ["trip-analytics", id] });
},
});
}
export function useDeleteTrip() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/trips/${id}`, { method: "DELETE" });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
});
}
export function useAssignTransactionsToTrip() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ tripId, transactionIds }: { tripId: number | null; transactionIds: number[] }) => {
const res = await fetch("/api/transactions/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "assign_trip", ids: transactionIds, trip_id: tripId }),
});
if (!res.ok) throw new Error("Failed to assign trip");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["trips"] });
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
},
});
}
+234
View File
@@ -37,6 +37,10 @@ export interface TransactionRow {
tags: TagRow[]; tags: TagRow[];
// splits // splits
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[]; splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
// trip
trip_id: number | null;
trip_name: string | null;
trip_color: string | null;
} }
export interface StatementRow { export interface StatementRow {
@@ -83,6 +87,7 @@ interface TransactionFilters {
amount_min?: number; amount_min?: number;
amount_max?: number; amount_max?: number;
has_split?: string; has_split?: string;
trip_id?: string;
} }
export async function getTransactions(ownerId: number, filters: TransactionFilters) { export async function getTransactions(ownerId: number, filters: TransactionFilters) {
@@ -154,6 +159,12 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
} else if (filters.has_split === "no") { } else if (filters.has_split === "no") {
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`); conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
} }
if (filters.trip_id === "unassigned") {
conditions.push(`o.trip_id IS NULL`);
} else if (filters.trip_id) {
conditions.push(`o.trip_id = $${paramIdx++}`);
params.push(Number(filters.trip_id));
}
const where = `WHERE ${conditions.join(" AND ")}`; const where = `WHERE ${conditions.join(" AND ")}`;
@@ -181,6 +192,9 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
COALESCE(t.owner_id, s.owner_id) as owner_id, COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name, p.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at, COALESCE(src.created_at, t.created_at) as created_at,
o.trip_id,
tr.name as trip_name,
tr.color as trip_color,
txn_tags.tags, txn_tags.tags,
txn_splits.splits txn_splits.splits
FROM transactions t FROM transactions t
@@ -188,6 +202,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id) LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN trips tr ON tr.id = o.trip_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
FROM transaction_tags tt FROM transaction_tags tt
@@ -604,3 +619,222 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data, splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
})); }));
} }
// ─── Trips ───────────────────────────────────────────────────────────────────
export interface TripRow {
id: number;
owner_id: number;
name: string;
description: string | null;
start_date: string | null;
end_date: string | null;
color: string;
archived: boolean;
created_at: string;
total_spend: number;
transaction_count: number;
}
export interface TripAnalytics {
trip: TripRow;
total_spend: number;
transaction_count: number;
num_days: number;
daily_average: number;
category_breakdown: { category: string; amount: number; count: number }[];
daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[];
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
}
export async function getTrips(ownerId: number): Promise<TripRow[]> {
return queryRaw<TripRow>(`
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
LEFT JOIN transactions tx ON tx.id = o.transaction_id
WHERE t.owner_id = $1
GROUP BY t.id
ORDER BY t.created_at DESC
`, [ownerId]);
}
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
const rows = await queryRaw<TripRow>(`
SELECT
t.*,
COALESCE(SUM(
CASE WHEN tx.transaction_type IN ('debit','fee','interest') THEN tx.amount ELSE 0 END
), 0)::float AS total_spend,
COUNT(o.transaction_id)::int AS transaction_count
FROM trips t
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
LEFT JOIN transactions tx ON tx.id = o.transaction_id
WHERE t.id = $1 AND t.owner_id = $2
GROUP BY t.id
`, [id, ownerId]);
return rows[0] ?? null;
}
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> {
const trip = await getTripById(tripId, ownerId);
if (!trip) throw new Error("Trip not found");
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, tx.category, 'other') AS category,
SUM(tx.amount)::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId]),
queryRaw<{ date: string; amount: number }>(`
SELECT
tx.transaction_date::text AS date,
SUM(tx.amount)::float AS amount
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 1
`, [tripId]),
queryRaw<{ merchant: string; amount: number; count: number }>(`
SELECT
COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant,
SUM(tx.amount)::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10
`, [tripId]),
queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(`
SELECT
tg.id AS tag_id, tg.name, tg.color,
SUM(tx.amount)::float AS amount,
COUNT(DISTINCT tx.id)::int AS count
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_tags tt ON tt.transaction_id = tx.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY tg.id
ORDER BY 4 DESC
`, [tripId]),
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
SELECT
p.id AS participant_id,
p.name,
SUM(ts.share_percent / 100.0 * tx.amount)::float AS owed,
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS settled,
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * tx.amount ELSE 0 END)::float AS unsettled
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_splits ts ON ts.transaction_id = tx.id
JOIN participants p ON p.id = ts.participant_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
GROUP BY p.id
ORDER BY 3 DESC
`, [tripId]),
]);
const num_days = (trip.start_date && trip.end_date)
? Math.max(1, Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1)
: Math.max(dailyRows.length, 1);
return {
trip,
total_spend: trip.total_spend,
transaction_count: trip.transaction_count,
num_days,
daily_average: trip.total_spend / num_days,
category_breakdown: categoryRows,
daily_spend: dailyRows,
top_merchants: merchantRows,
tag_breakdown: tagRows,
participant_splits: splitRows,
};
}
export async function createTrip(
ownerId: number,
data: { name: string; description?: string | null; start_date?: string | null; end_date?: string | null; color?: string }
): Promise<TripRow> {
const rows = await queryRaw<TripRow>(`
INSERT INTO trips (owner_id, name, description, start_date, end_date, color)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, [ownerId, data.name, data.description ?? null, data.start_date ?? null, data.end_date ?? null, data.color ?? '#6366f1']);
return rows[0];
}
export async function updateTrip(
id: number,
ownerId: number,
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
): Promise<TripRow | null> {
const setClauses: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (data.name !== undefined) { setClauses.push(`name = $${idx++}`); params.push(data.name); }
if ('description' in data) { setClauses.push(`description = $${idx++}`); params.push(data.description ?? null); }
if ('start_date' in data) { setClauses.push(`start_date = $${idx++}`); params.push(data.start_date ?? null); }
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
if (!setClauses.length) return getTripById(id, ownerId);
params.push(id, ownerId);
const rows = await queryRaw<TripRow>(`
UPDATE trips SET ${setClauses.join(', ')}
WHERE id = $${idx++} AND owner_id = $${idx}
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, params);
return rows[0] ?? null;
}
export async function deleteTrip(id: number, ownerId: number): Promise<void> {
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
}
export async function assignTransactionsToTrip(
tripId: number | null,
transactionIds: number[]
): Promise<void> {
if (!transactionIds.length) return;
await queryRaw(`
INSERT INTO transaction_overrides (transaction_id, trip_id)
SELECT unnest($1::int[]), $2
ON CONFLICT (transaction_id)
DO UPDATE SET trip_id = EXCLUDED.trip_id
`, [transactionIds, tripId]);
}
export async function getTagTransactionIds(tagId: number): Promise<number[]> {
const rows = await queryRaw<{ transaction_id: number }>(
`SELECT transaction_id FROM transaction_tags WHERE tag_id = $1`,
[tagId]
);
return rows.map((r) => r.transaction_id);
}