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
+40 -1
View File
@@ -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"