feat(trips): trip tracking with analytics, tag conversion, and transaction assignment

Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
This commit is contained in:
2026-07-19 20:00:51 +10:00
parent b706973fb5
commit 48ec151c15
21 changed files with 1506 additions and 55 deletions
+95 -1
View File
@@ -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"] });
},
});
}