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
+22
View File
@@ -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>