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:
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+139
-21
@@ -1,21 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#ef4444", // red
|
||||
"#f97316", // orange
|
||||
"#eab308", // yellow
|
||||
"#22c55e", // green
|
||||
"#14b8a6", // teal
|
||||
"#3b82f6", // blue
|
||||
"#6b7280", // gray
|
||||
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
|
||||
];
|
||||
|
||||
function ConvertModal({
|
||||
tag,
|
||||
onClose,
|
||||
}: {
|
||||
tag: { id: number; name: string; color: string; transaction_count: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [deleteTag, setDeleteTag] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
async function handleConvert() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/tags/${tag.id}/convert-to-trip`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ start_date: startDate || null, end_date: endDate || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
const { trip } = await res.json();
|
||||
|
||||
if (deleteTag) {
|
||||
await fetch(`/api/tags/${tag.id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
router.push(`/trips/${trip.id}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to convert");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">Dates are optional — you can set them later from the trip page.</p>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteTag}
|
||||
onChange={(e) => setDeleteTag(e.target.checked)}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-zinc-400">Delete this tag after converting</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2 items-center">
|
||||
{error && <p className="text-red-400 text-xs mr-auto">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Converting…" : "Convert to Trip"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsPage() {
|
||||
const { data: tags, isLoading } = useTags();
|
||||
const createTag = useCreateTag();
|
||||
@@ -24,6 +131,7 @@ export default function TagsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||
const [error, setError] = useState("");
|
||||
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
@@ -83,28 +191,38 @@ export default function TagsPage() {
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<span className="text-sm font-medium">{tag.name}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(tag.id)}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setConvertTag(tag)}
|
||||
className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
title="Convert to Trip"
|
||||
>
|
||||
→ Trip
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(tag.id)}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{convertTag && (
|
||||
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user