Complete Phase 3, Phase 5 polish and hardening
Phase 3 — Investments: - Multi-currency support: holdings track purchase currency, FX rates convert to base for totals - Capital gains report using UK Section 104 pool method, grouped by tax year - Capital Gains tab added to Reports page Phase 5 — Polish & Hardening: - Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch - Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation - Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments - OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict - AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction - CLAUDE.md added for AI-assisted development context - README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
|
|
@ -99,7 +99,7 @@ export default function AccountList() {
|
|||
</div>
|
||||
|
||||
{nw && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Total Assets", value: nw.total_assets, positive: true },
|
||||
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
|
||||
|
|
@ -253,7 +253,7 @@ function AccountGroup({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => onEdit(account)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { X, Search, Loader2 } from "lucide-react";
|
||||
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
|
||||
import { useUiStore } from "@/store/uiStore";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const COMMON_CURRENCIES = ["GBP", "USD", "EUR", "JPY", "CAD", "AUD", "CHF"];
|
||||
|
||||
interface Account { id: string; name: string; type: string; }
|
||||
|
||||
interface Props {
|
||||
|
|
@ -12,6 +15,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
|
||||
const baseCurrency = useUiStore(s => s.currency);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<AssetSearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
|
@ -27,6 +31,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
account_id: investAccounts[0]?.id ?? "",
|
||||
quantity: "",
|
||||
price: "",
|
||||
currency: baseCurrency,
|
||||
fees: "0",
|
||||
date: format(new Date(), "yyyy-MM-dd"),
|
||||
});
|
||||
|
|
@ -58,7 +63,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
asset_id: selected.id,
|
||||
quantity: qty,
|
||||
avg_cost_basis: price,
|
||||
currency: selected.currency,
|
||||
currency: form.currency,
|
||||
});
|
||||
await addInvestmentTransaction({
|
||||
holding_id: holding.id,
|
||||
|
|
@ -66,7 +71,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
quantity: qty,
|
||||
price: price,
|
||||
fees: parseFloat(form.fees) || 0,
|
||||
currency: selected.currency,
|
||||
currency: form.currency,
|
||||
date: form.date,
|
||||
});
|
||||
onSuccess();
|
||||
|
|
@ -156,13 +161,22 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Price paid *</label>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={form.price}
|
||||
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
|
||||
className="rounded-md border border-input bg-background px-1.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={form.price}
|
||||
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Fees</label>
|
||||
|
|
@ -176,6 +190,12 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{selected && form.currency !== selected.currency && (
|
||||
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
|
||||
Price in <strong>{form.currency}</strong> — asset trades in <strong>{selected.currency}</strong>. Current values will be converted using live exchange rates.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { useParams, Link } from "react-router-dom";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getPriceHistory, getPortfolio } from "@/api/investments";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { useUiStore } from "@/store/uiStore";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import Plot from "react-plotly.js";
|
||||
|
||||
export default function AssetDetail() {
|
||||
const { assetId } = useParams<{ assetId: string }>();
|
||||
const baseCurrency = useUiStore(s => s.currency);
|
||||
|
||||
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
|
||||
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
|
||||
|
|
@ -46,10 +48,10 @@ export default function AssetDetail() {
|
|||
{/* Price header */}
|
||||
{latestPrice != null && (
|
||||
<div className="flex items-end gap-4">
|
||||
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
|
||||
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? baseCurrency)}</p>
|
||||
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
|
||||
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
|
||||
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? baseCurrency)} ({changePct.toFixed(2)}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPortfolio, deleteHolding } from "@/api/investments";
|
||||
import { getPortfolio, deleteHolding, updateHolding } from "@/api/investments";
|
||||
import type { HoldingResponse } from "@/api/investments";
|
||||
import { getAccounts } from "@/api/accounts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
|
||||
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
|
||||
import AddHoldingModal from "./AddHoldingModal";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
|
@ -13,9 +14,127 @@ const COLORS = [
|
|||
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
|
||||
];
|
||||
|
||||
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
|
||||
holding: HoldingResponse;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Delete holding?</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This will permanently remove <span className="font-medium text-foreground">{holding.symbol}</span> ({holding.asset_name}) and all its transaction history. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isPending ? "Deleting…" : "Delete holding"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditHoldingModal({ holding, onClose, onSuccess }: {
|
||||
holding: HoldingResponse;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [quantity, setQuantity] = useState(String(holding.quantity));
|
||||
const [avgCost, setAvgCost] = useState(String(holding.avg_cost_basis));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateHolding(holding.id, {
|
||||
quantity: parseFloat(quantity),
|
||||
avg_cost_basis: parseFloat(avgCost),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["portfolio"] });
|
||||
onSuccess();
|
||||
},
|
||||
onError: (e: any) => setError(e?.response?.data?.detail ?? "Failed to update holding"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl">
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Edit {holding.symbol}</h2>
|
||||
<p className="text-xs text-muted-foreground">{holding.asset_name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Quantity</label>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Avg cost basis ({holding.currency})</label>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={avgCost}
|
||||
onChange={(e) => setAvgCost(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !quantity || !avgCost}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{mutation.isPending ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editHolding, setEditHolding] = useState<HoldingResponse | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<HoldingResponse | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const { data: portfolio, isLoading } = useQuery({
|
||||
queryKey: ["portfolio"],
|
||||
|
|
@ -27,7 +146,14 @@ export default function PortfolioPage() {
|
|||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteHolding,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["portfolio"] });
|
||||
setDeleteTarget(null);
|
||||
setDeleteError(null);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setDeleteError(e?.response?.data?.detail ?? "Failed to delete holding");
|
||||
},
|
||||
});
|
||||
|
||||
const treemapData = portfolio?.holdings
|
||||
|
|
@ -75,7 +201,7 @@ export default function PortfolioPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Treemap */}
|
||||
{/* Allocation bar */}
|
||||
{treemapData.length > 1 && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Allocation</p>
|
||||
|
|
@ -127,7 +253,7 @@ export default function PortfolioPage() {
|
|||
<th className="text-right px-4 py-3">Value</th>
|
||||
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
|
||||
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
|
||||
<th className="w-16"></th>
|
||||
<th className="w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -173,16 +299,25 @@ export default function PortfolioPage() {
|
|||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-end gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<Link
|
||||
to={`/investments/${h.asset_id}`}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
title="View chart"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(h.id)}
|
||||
onClick={() => setEditHolding(h)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDeleteTarget(h); setDeleteError(null); }}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -196,6 +331,13 @@ export default function PortfolioPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg px-4 py-3 text-sm text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddHoldingModal
|
||||
accounts={accounts}
|
||||
|
|
@ -203,6 +345,23 @@ export default function PortfolioPage() {
|
|||
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editHolding && (
|
||||
<EditHoldingModal
|
||||
holding={editHolding}
|
||||
onClose={() => setEditHolding(null)}
|
||||
onSuccess={() => setEditHolding(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
holding={deleteTarget}
|
||||
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
|
||||
onCancel={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
isPending={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,30 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import {
|
||||
getNetWorthReport,
|
||||
getIncomeExpenseReport,
|
||||
getCashFlowReport,
|
||||
getCategoryBreakdown,
|
||||
getBudgetVsActual,
|
||||
getSpendingTrends,
|
||||
getSavingsRate,
|
||||
getBalanceSheet,
|
||||
} from "@/api/reports";
|
||||
import { getPortfolio, getPerformance, getCapitalGains } from "@/api/investments";
|
||||
import type { TaxYearSummary } from "@/api/investments";
|
||||
import type { BalanceSheetGroup } from "@/api/reports";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar,
|
||||
AreaChart, Area, BarChart, Bar, ComposedChart, Line,
|
||||
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Legend
|
||||
} from "recharts";
|
||||
import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard } from "lucide-react";
|
||||
import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard, PiggyBank } from "lucide-react";
|
||||
|
||||
const TABS = ["Balance Sheet", "Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const;
|
||||
const TABS = [
|
||||
"Balance Sheet", "Net Worth", "Income vs Expense",
|
||||
"Cash Flow", "Savings Rate", "Categories",
|
||||
"Budget vs Actual", "Spending Trends", "Investments", "Capital Gains",
|
||||
] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
const COLORS = [
|
||||
|
|
@ -70,7 +78,6 @@ function BalanceSheetTab() {
|
|||
const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0;
|
||||
if (noAccounts) return <EmptyChart message="No accounts found" />;
|
||||
|
||||
// Build stacked bar data: one bar for assets, one for liabilities
|
||||
const assetBarData = data.asset_groups.map((g, i) => ({
|
||||
name: g.label,
|
||||
value: Number(g.subtotal),
|
||||
|
|
@ -81,13 +88,10 @@ function BalanceSheetTab() {
|
|||
value: Number(g.subtotal),
|
||||
color: LIABILITY_COLORS[i % LIABILITY_COLORS.length],
|
||||
}));
|
||||
|
||||
// Single stacked bar chart showing asset composition vs liability composition
|
||||
const maxVal = Math.max(Number(data.total_assets), Number(data.total_liabilities), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary KPIs */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Assets</p>
|
||||
|
|
@ -105,11 +109,8 @@ function BalanceSheetTab() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual proportion bars */}
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<p className="text-sm font-medium">Asset & Liability Composition</p>
|
||||
|
||||
{/* Assets bar */}
|
||||
{assetBarData.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
|
|
@ -120,16 +121,9 @@ function BalanceSheetTab() {
|
|||
</div>
|
||||
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
|
||||
{assetBarData.map((seg) => (
|
||||
<div
|
||||
key={seg.name}
|
||||
className="transition-all duration-500"
|
||||
style={{
|
||||
width: `${(seg.value / maxVal) * 100}%`,
|
||||
background: seg.color,
|
||||
minWidth: seg.value > 0 ? "2px" : "0",
|
||||
}}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
|
||||
/>
|
||||
<div key={seg.name} className="transition-all duration-500"
|
||||
style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
|
|
@ -142,8 +136,6 @@ function BalanceSheetTab() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liabilities bar */}
|
||||
{liabilityBarData.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
|
|
@ -154,16 +146,9 @@ function BalanceSheetTab() {
|
|||
</div>
|
||||
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
|
||||
{liabilityBarData.map((seg) => (
|
||||
<div
|
||||
key={seg.name}
|
||||
className="transition-all duration-500"
|
||||
style={{
|
||||
width: `${(seg.value / maxVal) * 100}%`,
|
||||
background: seg.color,
|
||||
minWidth: seg.value > 0 ? "2px" : "0",
|
||||
}}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
|
||||
/>
|
||||
<div key={seg.name} className="transition-all duration-500"
|
||||
style={{ width: `${(seg.value / maxVal) * 100}%`, background: seg.color, minWidth: seg.value > 0 ? "2px" : "0" }}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
|
|
@ -178,9 +163,7 @@ function BalanceSheetTab() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Side-by-side account breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Assets */}
|
||||
<div className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
|
|
@ -205,7 +188,6 @@ function BalanceSheetTab() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liabilities */}
|
||||
<div className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
|
|
@ -320,40 +302,259 @@ function IncomeExpenseTab() {
|
|||
);
|
||||
}
|
||||
|
||||
function CashFlowTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["report-cash-flow"],
|
||||
queryFn: () => getCashFlowReport(),
|
||||
});
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
if (!data) return null;
|
||||
|
||||
const chartData = data.points.map(p => ({
|
||||
date: p.date,
|
||||
inflow: Number(p.inflow),
|
||||
outflow: Number(p.outflow),
|
||||
balance: Number(p.running_balance),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Inflow</p>
|
||||
<p className="text-xl font-bold tabular-nums text-success">{formatCurrency(Number(data.total_inflow), data.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Outflow</p>
|
||||
<p className="text-xl font-bold tabular-nums text-destructive">{formatCurrency(Number(data.total_outflow), data.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Net</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", Number(data.total_inflow) - Number(data.total_outflow) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{formatCurrency(Number(data.total_inflow) - Number(data.total_outflow), data.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{chartData.length === 0 ? <EmptyChart /> : (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Daily Cash Flow — Last 30 Days</p>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
|
||||
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
|
||||
<YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
|
||||
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
|
||||
<Legend />
|
||||
<Bar yAxisId="bars" dataKey="inflow" fill="#22c55e" name="Inflow" radius={[2, 2, 0, 0]} />
|
||||
<Bar yAxisId="bars" dataKey="outflow" fill="#ef4444" name="Outflow" radius={[2, 2, 0, 0]} />
|
||||
<Line yAxisId="line" type="monotone" dataKey="balance" stroke="#6366f1" strokeWidth={2} dot={false} name="Running Balance" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavingsRateTab() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ["report-savings-rate"], queryFn: () => getSavingsRate(12) });
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
if (!data || data.points.length === 0) return <EmptyChart message="No income/expense data yet" />;
|
||||
|
||||
const chartData = data.points.map(p => ({
|
||||
month: p.month,
|
||||
income: Number(p.income),
|
||||
expenses: Number(p.expenses),
|
||||
savings: Number(p.savings),
|
||||
rate: Number(p.savings_rate),
|
||||
}));
|
||||
const avg = Number(data.avg_savings_rate);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Avg Savings Rate</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", avg >= 20 ? "text-success" : avg >= 0 ? "text-warning" : "text-destructive")}>
|
||||
{avg >= 0 ? "" : ""}{avg.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">over {data.points.length} months</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
|
||||
<PiggyBank className="w-8 h-8 text-primary opacity-60" />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Best Month</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{chartData.reduce((best, p) => p.rate > best.rate ? p : best, chartData[0])?.month ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex items-center gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Latest Month Savings</p>
|
||||
<p className="text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(chartData[chartData.length - 1]?.savings ?? 0, data.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Savings Rate by Month</p>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
|
||||
<YAxis yAxisId="bars" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
|
||||
<YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `${v}%`} />
|
||||
<Tooltip formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} />
|
||||
<Legend />
|
||||
<Bar yAxisId="bars" dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
|
||||
<Bar yAxisId="bars" dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
|
||||
<Line yAxisId="rate" type="monotone" dataKey="rate" stroke="#6366f1" strokeWidth={2.5} dot={{ r: 3 }} name="Savings Rate %" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-secondary/30">
|
||||
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Month</th>
|
||||
<th className="text-right px-4 py-3">Income</th>
|
||||
<th className="text-right px-4 py-3">Expenses</th>
|
||||
<th className="text-right px-4 py-3">Saved</th>
|
||||
<th className="text-right px-4 py-3">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...chartData].reverse().map((row) => (
|
||||
<tr key={row.month} className="border-b border-border/50 hover:bg-secondary/20">
|
||||
<td className="px-4 py-2.5 font-medium">{row.month}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-success">{formatCurrency(row.income, data.currency)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-destructive">{formatCurrency(row.expenses, data.currency)}</td>
|
||||
<td className={cn("px-4 py-2.5 text-right tabular-nums", row.savings >= 0 ? "text-success" : "text-destructive")}>
|
||||
{formatCurrency(row.savings, data.currency)}
|
||||
</td>
|
||||
<td className={cn("px-4 py-2.5 text-right tabular-nums font-semibold", row.rate >= 20 ? "text-success" : row.rate >= 0 ? "text-foreground" : "text-destructive")}>
|
||||
{row.rate.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoriesTab() {
|
||||
const [drillCategory, setDrillCategory] = useState<string | null>(null);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
if (!data) return null;
|
||||
|
||||
const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount) }));
|
||||
const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount), category_id: i.category_id }));
|
||||
const activeIndex = drillCategory ? pieData.findIndex(p => p.name === drillCategory) : -1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-1">Expense Breakdown — This Month</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}
|
||||
{drillCategory && <span> · <button onClick={() => setDrillCategory(null)} className="text-primary underline">clear filter</button></span>}
|
||||
</p>
|
||||
{pieData.length === 0 ? <EmptyChart /> : (
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="flex gap-6 items-start flex-wrap">
|
||||
<ResponsiveContainer width={220} height={220}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={90} dataKey="value" paddingAngle={2}>
|
||||
{pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%" cy="50%"
|
||||
innerRadius={60} outerRadius={90}
|
||||
dataKey="value"
|
||||
paddingAngle={2}
|
||||
onClick={(entry) => setDrillCategory(drillCategory === entry.name ? null : entry.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{pieData.map((_, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
fill={COLORS[i % COLORS.length]}
|
||||
opacity={activeIndex === -1 || activeIndex === i ? 1 : 0.35}
|
||||
stroke={activeIndex === i ? "var(--foreground)" : "none"}
|
||||
strokeWidth={activeIndex === i ? 2 : 0}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex-1 space-y-2 min-w-48">
|
||||
{data.items.slice(0, 10).map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setDrillCategory(drillCategory === item.category_name ? null : item.category_name)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 text-sm rounded-lg px-2 py-1 transition-colors text-left",
|
||||
drillCategory === item.category_name ? "bg-secondary" : "hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
||||
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
|
||||
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), data.currency)}</span>
|
||||
<span className="text-xs text-muted-foreground w-10 text-right">{item.percent}%</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{drillCategory && (
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-3">Transactions in <span className="text-primary">{drillCategory}</span> this month</p>
|
||||
<DrillDownTransactions categoryId={data.items.find(i => i.category_name === drillCategory)?.category_id ?? null} currency={data.currency} dateFrom={data.date_from} dateTo={data.date_to} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DrillDownTransactions({ categoryId, currency, dateFrom, dateTo }: {
|
||||
categoryId: string | null; currency: string; dateFrom: string; dateTo: string;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["drilldown-txns", categoryId, dateFrom, dateTo],
|
||||
queryFn: async () => {
|
||||
const { getTransactions } = await import("@/api/transactions");
|
||||
return getTransactions({
|
||||
category_id: categoryId ?? undefined,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
page_size: 50,
|
||||
});
|
||||
},
|
||||
enabled: !!categoryId,
|
||||
});
|
||||
|
||||
if (!categoryId) return <p className="text-sm text-muted-foreground">Uncategorised transactions cannot be filtered here.</p>;
|
||||
if (isLoading) return <div className="h-20 animate-pulse bg-secondary/30 rounded-lg" />;
|
||||
if (!data?.items.length) return <p className="text-sm text-muted-foreground py-4 text-center">No transactions found.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{data.items.map((txn) => (
|
||||
<div key={txn.id} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0 text-sm">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium">{txn.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{txn.date}</p>
|
||||
</div>
|
||||
<span className="tabular-nums font-semibold text-destructive ml-4 shrink-0">
|
||||
{formatCurrency(Math.abs(Number(txn.amount)), currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -427,6 +628,207 @@ function SpendingTrendsTab() {
|
|||
);
|
||||
}
|
||||
|
||||
function InvestmentsTab() {
|
||||
const { data: portfolio, isLoading: loadingPortfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
|
||||
const { data: perf, isLoading: loadingPerf } = useQuery({ queryKey: ["investment-performance"], queryFn: getPerformance });
|
||||
|
||||
if (loadingPortfolio || loadingPerf) return <ChartSkeleton />;
|
||||
if (!portfolio || !perf) return null;
|
||||
|
||||
const noHoldings = portfolio.holdings.length === 0;
|
||||
if (noHoldings) return <EmptyChart message="No investment holdings yet" />;
|
||||
|
||||
const holdingsData = portfolio.holdings.map(h => ({
|
||||
name: h.symbol,
|
||||
value: Number(h.current_value ?? 0),
|
||||
gain: Number(h.unrealised_gain ?? 0),
|
||||
gainPct: Number(h.unrealised_gain_pct ?? 0),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Portfolio Value</p>
|
||||
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_value), perf.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
|
||||
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(portfolio.total_cost), perf.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Return</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{Number(perf.total_return) >= 0 ? "+" : ""}{formatCurrency(Number(perf.total_return), perf.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Return %</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", Number(perf.total_return_pct) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{Number(perf.total_return_pct) >= 0 ? "+" : ""}{Number(perf.total_return_pct).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-sm font-medium mb-4">Holdings Value</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={holdingsData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={60} />
|
||||
<Tooltip formatter={(v: number) => formatCurrency(v, perf.currency)} />
|
||||
<Bar dataKey="value" name="Current Value" radius={[0, 3, 3, 0]}>
|
||||
{holdingsData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.gain >= 0 ? "#22c55e" : "#ef4444"} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-secondary/30">
|
||||
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Asset</th>
|
||||
<th className="text-right px-4 py-3">Qty</th>
|
||||
<th className="text-right px-4 py-3">Avg Cost</th>
|
||||
<th className="text-right px-4 py-3">Current Price</th>
|
||||
<th className="text-right px-4 py-3">Value</th>
|
||||
<th className="text-right px-4 py-3">Gain / Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{portfolio.holdings.map((h) => (
|
||||
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{h.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-32">{h.asset_name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{Number(h.quantity).toFixed(4)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(h.avg_cost_basis), h.currency)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{h.current_price != null ? formatCurrency(Number(h.current_price), h.currency) : "—"}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-semibold">{h.current_value != null ? formatCurrency(Number(h.current_value), h.currency) : "—"}</td>
|
||||
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", h.unrealised_gain != null && Number(h.unrealised_gain) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{h.unrealised_gain != null ? (
|
||||
<>
|
||||
{Number(h.unrealised_gain) >= 0 ? "+" : ""}{formatCurrency(Number(h.unrealised_gain), h.currency)}
|
||||
<span className="text-xs font-normal ml-1">({Number(h.unrealised_gain_pct ?? 0).toFixed(1)}%)</span>
|
||||
</>
|
||||
) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapitalGainsTab() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ["capital-gains"], queryFn: getCapitalGains });
|
||||
const [selectedYear, setSelectedYear] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
if (!data || data.tax_years.length === 0) {
|
||||
return <EmptyChart message="No disposals recorded yet" />;
|
||||
}
|
||||
|
||||
const activeYear: TaxYearSummary = data.tax_years.find(y => y.tax_year === selectedYear) ?? data.tax_years[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">UK tax year:</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{data.tax_years.map(y => (
|
||||
<button
|
||||
key={y.tax_year}
|
||||
onClick={() => setSelectedYear(y.tax_year)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium border transition-colors",
|
||||
(selectedYear ?? data.tax_years[0].tax_year) === y.tax_year
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{y.tax_year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Proceeds</p>
|
||||
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Cost</p>
|
||||
<p className="text-xl font-bold tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Net Gain / Loss</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-secondary/30">
|
||||
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Date</th>
|
||||
<th className="text-left px-4 py-3">Asset</th>
|
||||
<th className="text-right px-4 py-3">Qty</th>
|
||||
<th className="text-right px-4 py-3">Proceeds</th>
|
||||
<th className="text-right px-4 py-3">Cost</th>
|
||||
<th className="text-right px-4 py-3">Gain / Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeYear.disposals.map((d, i) => {
|
||||
const gain = Number(d.gain);
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/50 hover:bg-secondary/20">
|
||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">{d.date}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{d.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-40">{d.asset_name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{Number(d.quantity).toFixed(4)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.proceeds), d.currency)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(d.cost), d.currency)}</td>
|
||||
<td className={cn("px-4 py-3 text-right tabular-nums font-semibold", gain >= 0 ? "text-success" : "text-destructive")}>
|
||||
{gain >= 0 ? "+" : ""}{formatCurrency(gain, d.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="border-t border-border bg-secondary/20">
|
||||
<tr className="text-sm font-semibold">
|
||||
<td colSpan={3} className="px-4 py-3 text-muted-foreground">Total ({activeYear.disposals.length} disposal{activeYear.disposals.length !== 1 ? "s" : ""})</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_proceeds), activeYear.currency)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(Number(activeYear.total_cost), activeYear.currency)}</td>
|
||||
<td className={cn("px-4 py-3 text-right tabular-nums", Number(activeYear.total_gain) >= 0 ? "text-success" : "text-destructive")}>
|
||||
{Number(activeYear.total_gain) >= 0 ? "+" : ""}{formatCurrency(Number(activeYear.total_gain), activeYear.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Calculated using the UK Section 104 pool method. Currency conversion uses current exchange rates as an approximation for historical trades. Not financial advice — verify with a qualified accountant before filing.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -478,9 +880,13 @@ export default function ReportsPage() {
|
|||
{activeTab === "Balance Sheet" && <BalanceSheetTab />}
|
||||
{activeTab === "Net Worth" && <NetWorthTab />}
|
||||
{activeTab === "Income vs Expense" && <IncomeExpenseTab />}
|
||||
{activeTab === "Cash Flow" && <CashFlowTab />}
|
||||
{activeTab === "Savings Rate" && <SavingsRateTab />}
|
||||
{activeTab === "Categories" && <CategoriesTab />}
|
||||
{activeTab === "Budget vs Actual" && <BudgetVsActualTab />}
|
||||
{activeTab === "Spending Trends" && <SpendingTrendsTab />}
|
||||
{activeTab === "Investments" && <InvestmentsTab />}
|
||||
{activeTab === "Capital Gains" && <CapitalGainsTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import {
|
|||
getTotpSetup, enableTotp, disableTotp,
|
||||
changePassword, updateProfile, exportData, getMe,
|
||||
} from "@/api/auth";
|
||||
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
|
||||
import type { BackupFile } from "@/api/admin";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
User, Shield, MonitorSmartphone, Download,
|
||||
User, Shield, MonitorSmartphone, Download, HardDrive,
|
||||
Loader2, CheckCircle, Eye, EyeOff, Trash2,
|
||||
LogOut, QrCode, KeyRound, AlertTriangle,
|
||||
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
|
||||
} from "lucide-react";
|
||||
|
||||
const SECTIONS = [
|
||||
|
|
@ -20,6 +22,7 @@ const SECTIONS = [
|
|||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
|
||||
{ id: "data", label: "Data", icon: Download },
|
||||
{ id: "backups", label: "Backups", icon: HardDrive },
|
||||
] as const;
|
||||
|
||||
type Section = (typeof SECTIONS)[number]["id"];
|
||||
|
|
@ -60,6 +63,7 @@ export default function SettingsPage() {
|
|||
{section === "security" && <SecuritySection />}
|
||||
{section === "sessions" && <SessionsSection />}
|
||||
{section === "data" && <DataSection />}
|
||||
{section === "backups" && <BackupsSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -483,6 +487,167 @@ function SessionsSection() {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Backups ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function BackupsSection() {
|
||||
const qc = useQueryClient();
|
||||
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
|
||||
const [restoreSuccess, setRestoreSuccess] = useState("");
|
||||
const [restoreError, setRestoreError] = useState("");
|
||||
|
||||
const { data: backups = [], isLoading } = useQuery({
|
||||
queryKey: ["backups"],
|
||||
queryFn: listBackups,
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: triggerBackup,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["backups"] }),
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (filename: string) => restoreBackup(filename),
|
||||
onSuccess: (_, filename) => {
|
||||
setRestoreTarget(null);
|
||||
setRestoreSuccess(`Restored from ${filename}. Reload the page to see updated data.`);
|
||||
setRestoreError("");
|
||||
setTimeout(() => setRestoreSuccess(""), 8000);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
setRestoreError(e?.response?.data?.detail ?? "Restore failed");
|
||||
setRestoreTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Trigger */}
|
||||
<div className={cardCls}>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-muted-foreground" />
|
||||
<SectionTitle>Database Backups</SectionTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at <code className="text-xs bg-secondary px-1 py-0.5 rounded">/app/backups</code>.
|
||||
</p>
|
||||
|
||||
{triggerMutation.isSuccess && <SuccessBanner message="Backup created successfully" />}
|
||||
{triggerMutation.isError && <ErrorBanner message={(triggerMutation.error as any)?.response?.data?.detail ?? "Backup failed"} />}
|
||||
|
||||
<button
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{triggerMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{triggerMutation.isPending ? "Creating backup…" : "Backup now"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Backup list */}
|
||||
<div className={cardCls}>
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionTitle>Stored Backups</SectionTitle>
|
||||
<button
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ["backups"] })}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
title="Refresh list"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{restoreSuccess && <SuccessBanner message={restoreSuccess} />}
|
||||
{restoreError && <ErrorBanner message={restoreError} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1,2,3].map(i => <div key={i} className="h-12 bg-secondary/30 rounded-lg animate-pulse" />)}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No backups yet — click "Backup now" to create one.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((b: BackupFile) => (
|
||||
<div key={b.filename} className="flex items-center gap-3 p-3 rounded-lg border border-border bg-secondary/20">
|
||||
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium font-mono truncate">{b.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => downloadBackup(b.filename)}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreTarget(b.filename)}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog */}
|
||||
{restoreTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Restore this backup?</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This will <strong>overwrite all current data</strong> with the contents of:
|
||||
</p>
|
||||
<p className="text-xs font-mono bg-secondary px-2 py-1 rounded mt-2 break-all">{restoreTarget}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">This cannot be undone. Consider downloading your current backup first.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setRestoreTarget(null)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restoreMutation.mutate(restoreTarget)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{restoreMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{restoreMutation.isPending ? "Restoring…" : "Yes, restore"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DataSection() {
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
|
|||
<button
|
||||
onClick={() => deleteMutation.mutate(att.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all p-1 rounded"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all p-1 rounded"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import type { Transaction } from "@/api/transactions";
|
|||
import { getAccounts } from "@/api/accounts";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { format } from "date-fns";
|
||||
import { format, startOfMonth, subMonths, startOfYear } from "date-fns";
|
||||
import {
|
||||
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
|
||||
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip
|
||||
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw
|
||||
} from "lucide-react";
|
||||
import TransactionFormModal from "./TransactionFormModal";
|
||||
import TransactionDetailDrawer from "./TransactionDetailDrawer";
|
||||
|
|
@ -28,6 +28,23 @@ const TYPE_ICONS = {
|
|||
investment: TrendingUp,
|
||||
};
|
||||
|
||||
type DatePreset = "this_month" | "last_3_months" | "this_year" | "all";
|
||||
|
||||
const DATE_PRESETS: { label: string; value: DatePreset }[] = [
|
||||
{ label: "This month", value: "this_month" },
|
||||
{ label: "Last 3 months", value: "last_3_months" },
|
||||
{ label: "This year", value: "this_year" },
|
||||
{ label: "All time", value: "all" },
|
||||
];
|
||||
|
||||
function presetToDates(preset: DatePreset): { date_from?: string; date_to?: string } {
|
||||
const today = new Date();
|
||||
if (preset === "this_month") return { date_from: format(startOfMonth(today), "yyyy-MM-dd") };
|
||||
if (preset === "last_3_months") return { date_from: format(startOfMonth(subMonths(today, 2)), "yyyy-MM-dd") };
|
||||
if (preset === "this_year") return { date_from: format(startOfYear(today), "yyyy-MM-dd") };
|
||||
return {};
|
||||
}
|
||||
|
||||
export default function TransactionList() {
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
|
@ -35,15 +52,21 @@ export default function TransactionList() {
|
|||
const [search, setSearch] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterAccount, setFilterAccount] = useState("");
|
||||
const [datePreset, setDatePreset] = useState<DatePreset>("this_month");
|
||||
const [recurringOnly, setRecurringOnly] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const dates = presetToDates(datePreset);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["transactions", { search, filterType, filterAccount, page }],
|
||||
queryKey: ["transactions", { search, filterType, filterAccount, datePreset, recurringOnly, page }],
|
||||
queryFn: () =>
|
||||
getTransactions({
|
||||
search: search || undefined,
|
||||
type: filterType || undefined,
|
||||
account_id: filterAccount || undefined,
|
||||
is_recurring: recurringOnly ? true : undefined,
|
||||
...dates,
|
||||
page,
|
||||
page_size: 50,
|
||||
}),
|
||||
|
|
@ -82,24 +105,54 @@ export default function TransactionList() {
|
|||
{data ? `${data.total} transactions` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Link
|
||||
to="/transactions/import"
|
||||
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import CSV
|
||||
<span className="hidden sm:inline">Import CSV</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
<span className="hidden sm:inline">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date presets */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => { setDatePreset(p.value); setPage(1); }}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors",
|
||||
datePreset === p.value
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { setRecurringOnly(!recurringOnly); setPage(1); }}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors flex items-center gap-1.5 ml-auto",
|
||||
recurringOnly
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Recurring only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
|
|
@ -172,7 +225,8 @@ export default function TransactionList() {
|
|||
onClick={() => setSelectedTxn(txn)}
|
||||
>
|
||||
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(txn.date), "dd MMM yyyy")}
|
||||
<span className="hidden sm:inline">{format(new Date(txn.date), "dd MMM yyyy")}</span>
|
||||
<span className="sm:hidden">{format(new Date(txn.date), "dd MMM")}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -205,7 +259,7 @@ export default function TransactionList() {
|
|||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(txn.id)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue