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:
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -17,6 +17,7 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
@@ -48,35 +49,6 @@ function deltaColor(n: number): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 };
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
groceries: "#22c55e",
|
||||
dining: "#f97316",
|
||||
transport: "#06b6d4",
|
||||
fuel: "#eab308",
|
||||
shopping: "#ec4899",
|
||||
utilities: "#8b5cf6",
|
||||
entertainment: "#f43f5e",
|
||||
travel: "#0ea5e9",
|
||||
health: "#10b981",
|
||||
insurance: "#64748b",
|
||||
subscriptions: "#a78bfa",
|
||||
cash_advance: "#dc2626",
|
||||
government: "#78716c",
|
||||
education: "#3b82f6",
|
||||
rent: "#d97706",
|
||||
transfers: "#6b7280",
|
||||
income: "#34d399",
|
||||
investment: "#818cf8",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#86efac",
|
||||
gifts: "#fcd34d",
|
||||
charity: "#a3e635",
|
||||
home_goods: "#67e8f9",
|
||||
home_maintenance: "#c084fc",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+134
-16
@@ -1,21 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#ef4444", // red
|
||||
"#f97316", // orange
|
||||
"#eab308", // yellow
|
||||
"#22c55e", // green
|
||||
"#14b8a6", // teal
|
||||
"#3b82f6", // blue
|
||||
"#6b7280", // gray
|
||||
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
|
||||
];
|
||||
|
||||
function ConvertModal({
|
||||
tag,
|
||||
onClose,
|
||||
}: {
|
||||
tag: { id: number; name: string; color: string; transaction_count: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [deleteTag, setDeleteTag] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
async function handleConvert() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/tags/${tag.id}/convert-to-trip`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ start_date: startDate || null, end_date: endDate || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
const { trip } = await res.json();
|
||||
|
||||
if (deleteTag) {
|
||||
await fetch(`/api/tags/${tag.id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
router.push(`/trips/${trip.id}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to convert");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">Dates are optional — you can set them later from the trip page.</p>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteTag}
|
||||
onChange={(e) => setDeleteTag(e.target.checked)}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-zinc-400">Delete this tag after converting</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2 items-center">
|
||||
{error && <p className="text-red-400 text-xs mr-auto">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Converting…" : "Convert to Trip"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsPage() {
|
||||
const { data: tags, isLoading } = useTags();
|
||||
const createTag = useCreateTag();
|
||||
@@ -24,6 +131,7 @@ export default function TagsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||
const [error, setError] = useState("");
|
||||
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
@@ -83,28 +191,38 @@ export default function TagsPage() {
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<span className="text-sm font-medium">{tag.name}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setConvertTag(tag)}
|
||||
className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
title="Convert to Trip"
|
||||
>
|
||||
→ Trip
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(tag.id)}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{convertTag && (
|
||||
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { SplitModal } from "@/components/split-modal";
|
||||
import { TagPicker } from "@/components/tag-picker";
|
||||
@@ -477,6 +477,7 @@ function TransactionsContent() {
|
||||
amount_min: undefined as number | undefined,
|
||||
amount_max: undefined as number | undefined,
|
||||
has_split: "" as string,
|
||||
trip_id: "" as string,
|
||||
});
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
||||
@@ -506,6 +507,7 @@ function TransactionsContent() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkCategory, setBulkCategory] = useState("");
|
||||
const [bulkTagId, setBulkTagId] = useState("");
|
||||
const [bulkTripId, setBulkTripId] = useState("");
|
||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
||||
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
||||
@@ -524,6 +526,8 @@ function TransactionsContent() {
|
||||
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const bulkAction = useBulkAction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const assignToTrip = useAssignTransactionsToTrip();
|
||||
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelected((prev) => {
|
||||
@@ -687,6 +691,17 @@ function TransactionsContent() {
|
||||
<option value="yes">Split only</option>
|
||||
<option value="no">Unsplit only</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.trip_id}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, trip_id: e.target.value, offset: 0 }))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||
>
|
||||
<option value="">All Trips</option>
|
||||
<option value="unassigned">No Trip</option>
|
||||
{trips.map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
@@ -748,6 +763,30 @@ function TransactionsContent() {
|
||||
>
|
||||
Tag
|
||||
</button>
|
||||
<select
|
||||
value={bulkTripId}
|
||||
onChange={(e) => setBulkTripId(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">Assign trip…</option>
|
||||
<option value="remove">Remove from trip</option>
|
||||
{trips.filter((t) => !t.archived).map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={!bulkTripId || assignToTrip.isPending}
|
||||
onClick={() => {
|
||||
const tripId = bulkTripId === "remove" ? null : Number(bulkTripId);
|
||||
assignToTrip.mutate(
|
||||
{ tripId, transactionIds: Array.from(selected) },
|
||||
{ onSuccess: () => { setSelected(new Set()); setBulkTripId(""); } }
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{bulkTripId === "remove" ? "Remove" : "Assign"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useAddTransactionTag,
|
||||
useRemoveTransactionTag,
|
||||
useTransactionSplits,
|
||||
useTrips,
|
||||
} from "@/lib/hooks";
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
@@ -93,6 +94,7 @@ export function EditTransactionModal({
|
||||
}) {
|
||||
const isManual = !transaction.statement_id;
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
|
||||
// Editable override fields
|
||||
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
|
||||
@@ -105,6 +107,8 @@ export function EditTransactionModal({
|
||||
const [description, setDescription] = useState(transaction.description);
|
||||
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
|
||||
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
||||
|
||||
@@ -136,6 +140,9 @@ export function EditTransactionModal({
|
||||
patch.amount = parseFloat(amount);
|
||||
}
|
||||
|
||||
if (tripId !== (transaction.trip_id ?? null))
|
||||
patch.trip_id = tripId;
|
||||
|
||||
await updateTxn.mutateAsync(patch);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -255,6 +262,21 @@ export function EditTransactionModal({
|
||||
</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 */}
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1.5">Tags</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ href: "/trips", label: "Trips", icon: "map-pin" },
|
||||
{ href: "/shared", label: "Shared", icon: "users" },
|
||||
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||
{ 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" />
|
||||
</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: (
|
||||
<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" />
|
||||
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface TransactionsResponse {
|
||||
@@ -27,6 +28,7 @@ interface TransactionFilters {
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
@@ -137,6 +139,7 @@ export function useUpdateTransaction() {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -801,3 +804,94 @@ export function useMerchantTransactions(merchant: string | null) {
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface TransactionRow {
|
||||
tags: TagRow[];
|
||||
// splits
|
||||
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 {
|
||||
@@ -83,6 +87,7 @@ interface TransactionFilters {
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
}
|
||||
|
||||
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") {
|
||||
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 ")}`;
|
||||
|
||||
@@ -181,6 +192,9 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name,
|
||||
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_splits.splits
|
||||
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 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 trips tr ON tr.id = o.trip_id
|
||||
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
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user