Add recurring transaction detection, subscriptions page, and UK tax reporting

- Recurring service: auto-detects direct debits/subscriptions from CSV imports
  using frequency analysis; manual toggle in transaction detail drawer
- Subscriptions page (/subscriptions): groups recurring payments with monthly
  cost equivalents, next-payment badges, and re-scan trigger
- UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax
  calculations, configurable rate tables per tax year (pre-seeded 2024/25 and
  2025/26), editable in-app so Budget changes need no rebuild
- Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals
  with RLS; seeds 2025/2026 rate configs for existing users
- Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so
  they render correctly across all dark/light themes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 21:40:02 +00:00
parent 0b326cbd87
commit afb5e99bb2
48 changed files with 6238 additions and 39 deletions

30
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
import tseslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
export default [
{
ignores: ["dist/**", "node_modules/**"],
},
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
plugins: {
"@typescript-eslint": tseslint,
"react-hooks": reactHooks,
},
rules: {
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
},
];

View file

@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
"lint": "eslint src"
},
"dependencies": {
"react": "^18.3.1",

View file

@ -16,6 +16,8 @@ import PortfolioPage from "@/pages/investments/PortfolioPage";
import AssetDetail from "@/pages/investments/AssetDetail";
import PredictionsPage from "@/pages/predictions/PredictionsPage";
import SettingsPage from "@/pages/settings/SettingsPage";
import SubscriptionsPage from "@/pages/subscriptions/SubscriptionsPage";
import TaxPage from "@/pages/tax/TaxPage";
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@ -53,7 +55,9 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} />
<Route path="/investments" element={<PortfolioPage />} />
<Route path="/investments/:assetId" element={<AssetDetail />} />
<Route path="/tax" element={<TaxPage />} />
<Route path="/predictions" element={<PredictionsPage />} />
<Route path="/subscriptions" element={<SubscriptionsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</AppShell>

View file

@ -0,0 +1,28 @@
import { api } from "./client";
export interface Subscription {
name: string;
amount: number;
frequency: string;
next_expected: string | null;
last_paid: string | null;
account_id: string;
account_name: string | null;
transaction_ids: string[];
latest_transaction_id: string;
monthly_equivalent: number;
confidence: number;
manually_set: boolean;
}
export interface SubscriptionsSummary {
total_monthly_equivalent: number;
currency: string;
subscriptions: Subscription[];
}
export const getSubscriptions = (): Promise<SubscriptionsSummary> =>
api.get("/subscriptions").then((r: { data: SubscriptionsSummary }) => r.data);
export const triggerDetection = (): Promise<{ newly_tagged: number; total_recurring: number }> =>
api.post("/transactions/detect-recurring").then((r: { data: { newly_tagged: number; total_recurring: number } }) => r.data);

309
frontend/src/api/tax.ts Normal file
View file

@ -0,0 +1,309 @@
import { api } from "./client";
// ---------------------------------------------------------------------------
// Query key constants
// ---------------------------------------------------------------------------
export const TAX_QUERY_KEYS = {
configuredYears: ["tax-configured-years"] as const,
rateConfig: (taxYear: number) => ["tax-rate-config", taxYear] as const,
profile: (taxYear: number) => ["tax-profile", taxYear] as const,
payslips: (taxYear: number) => ["tax-payslips", taxYear] as const,
cgtDisposals: (taxYear: number) => ["tax-cgt-disposals", taxYear] as const,
report: (taxYear: number) => ["tax-report", taxYear] as const,
};
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
export interface TaxRateConfig {
tax_year: number;
rates: {
income_tax?: { bands: RateBand[] };
ni?: { bands: RateBand[] };
cgt?: { exempt: number; basic_rate: number; higher_rate: number };
dividend?: {
allowance: number;
basic_rate: number;
higher_rate: number;
additional_rate: number;
};
};
updated_at: string;
}
export interface RateBand {
from: number;
to: number | null;
rate: number;
}
export interface TaxRateConfigUpdate {
income_tax?: { bands: RateBand[] };
ni?: { bands: RateBand[] };
cgt?: { exempt: number; basic_rate: number; higher_rate: number };
dividend?: {
allowance: number;
basic_rate: number;
higher_rate: number;
additional_rate: number;
};
}
export interface TaxProfile {
id: string;
tax_year: number;
tax_code: string;
employer_name: string | null;
is_cumulative: boolean;
created_at: string;
updated_at: string;
}
export interface TaxProfileCreate {
tax_code?: string;
employer_name?: string | null;
is_cumulative?: boolean;
}
export interface Payslip {
id: string;
tax_profile_id: string;
period_month: number | null;
period_year: number;
gross_pay: string;
income_tax_withheld: string;
ni_withheld: string;
net_pay: string;
is_p60: boolean;
notes: string | null;
created_at: string;
}
export interface PayslipCreate {
period_month?: number | null;
period_year: number;
gross_pay: number;
income_tax_withheld: number;
ni_withheld: number;
net_pay: number;
notes?: string | null;
}
export interface P60Entry {
gross_pay: number;
income_tax_withheld: number;
ni_withheld: number;
net_pay: number;
}
export interface ManualDisposal {
id: string;
tax_year: number;
disposal_date: string;
asset_description: string;
proceeds: string;
cost_basis: string;
gain_loss: string;
notes: string | null;
created_at: string;
}
export interface ManualDisposalCreate {
disposal_date: string;
asset_description: string;
proceeds: number;
cost_basis: number;
notes?: string | null;
}
// ---------------------------------------------------------------------------
// Tax report nested types
// ---------------------------------------------------------------------------
export interface BandBreakdown {
rate: number;
taxable: number;
tax: number;
from?: number;
to?: number | null;
}
export interface InvestmentDisposalItem {
date: string;
asset: string;
symbol: string;
quantity: string;
proceeds: string;
cost_basis: string;
fees: string;
gain_loss: string;
}
export interface DividendTransactionItem {
date: string;
asset: string;
symbol: string;
amount: string;
}
export interface TaxReport {
tax_year: number;
tax_year_display: string;
profile: TaxProfile | null;
income: {
gross_income: string;
income_tax_withheld: string;
ni_withheld: string;
payslips: Payslip[];
};
income_tax: {
personal_allowance: string;
taxable_income: string;
liability: string;
band_breakdown: BandBreakdown[];
withheld: string;
owed: string;
};
ni: {
liability: string;
band_breakdown: BandBreakdown[];
withheld: string;
owed: string;
};
cgt: {
gross_gain: string;
exempt: string;
taxable_gain: string;
liability: string;
band_breakdown: BandBreakdown[];
investment_disposals: InvestmentDisposalItem[];
manual_disposals: ManualDisposal[];
total_gain: string;
};
dividends: {
gross_dividends: string;
allowance: string;
taxable_dividends: string;
liability: string;
band_breakdown: BandBreakdown[];
dividend_transactions: DividendTransactionItem[];
};
summary: {
total_liability: string;
total_withheld: string;
net_owed: string;
overpaid: boolean;
};
}
// ---------------------------------------------------------------------------
// API functions — rate configs
// ---------------------------------------------------------------------------
export async function getConfiguredYears(): Promise<number[]> {
const res = await api.get("/tax/rate-configs");
return res.data;
}
export async function getRateConfig(taxYear: number): Promise<TaxRateConfig> {
const res = await api.get(`/tax/rate-configs/${taxYear}`);
return res.data;
}
export async function upsertRateConfig(
taxYear: number,
data: TaxRateConfigUpdate
): Promise<TaxRateConfig> {
const res = await api.put(`/tax/rate-configs/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — tax profile
// ---------------------------------------------------------------------------
export async function getTaxProfile(taxYear: number): Promise<TaxProfile> {
const res = await api.get(`/tax/profile/${taxYear}`);
return res.data;
}
export async function upsertTaxProfile(
taxYear: number,
data: TaxProfileCreate
): Promise<TaxProfile> {
const res = await api.put(`/tax/profile/${taxYear}`, data);
return res.data;
}
// ---------------------------------------------------------------------------
// API functions — payslips
// ---------------------------------------------------------------------------
export async function getPayslips(taxYear: number): Promise<Payslip[]> {
const res = await api.get(`/tax/payslips/${taxYear}`);
return res.data;
}
export async function createPayslip(
taxYear: number,
data: PayslipCreate
): Promise<Payslip> {
const res = await api.post(`/tax/payslips/${taxYear}`, data);
return res.data;
}
export async function updatePayslip(
id: string,
data: Partial<PayslipCreate>
): Promise<Payslip> {
const res = await api.put(`/tax/payslips/${id}`, data);
return res.data;
}
export async function deletePayslip(id: string): Promise<void> {
await api.delete(`/tax/payslips/${id}`);
}
export async function enterP60(taxYear: number, data: P60Entry): Promise<void> {
await api.post(`/tax/payslips/${taxYear}/p60`, data);
}
// ---------------------------------------------------------------------------
// API functions — manual CGT disposals
// ---------------------------------------------------------------------------
export async function getCgtDisposals(taxYear: number): Promise<ManualDisposal[]> {
const res = await api.get(`/tax/cgt-disposals/${taxYear}`);
return res.data;
}
export async function createCgtDisposal(
taxYear: number,
data: ManualDisposalCreate
): Promise<ManualDisposal> {
const res = await api.post(`/tax/cgt-disposals/${taxYear}`, data);
return res.data;
}
export async function updateCgtDisposal(
id: string,
data: Partial<ManualDisposalCreate>
): Promise<ManualDisposal> {
const res = await api.put(`/tax/cgt-disposals/${id}`, data);
return res.data;
}
export async function deleteCgtDisposal(id: string): Promise<void> {
await api.delete(`/tax/cgt-disposals/${id}`);
}
// ---------------------------------------------------------------------------
// API functions — report
// ---------------------------------------------------------------------------
export async function getTaxReport(taxYear: number): Promise<TaxReport> {
const res = await api.get(`/tax/report/${taxYear}`);
return res.data;
}

View file

@ -25,6 +25,7 @@ export interface Transaction {
notes: string | null;
tags: string[];
is_recurring: boolean;
recurring_rule: Record<string, unknown> | null;
attachment_refs: AttachmentRef[];
created_at: string;
updated_at: string;
@ -43,6 +44,8 @@ export interface TransactionCreate {
merchant?: string;
notes?: string;
tags?: string[];
is_recurring?: boolean;
recurring_rule?: Record<string, unknown> | null;
}
export interface TransactionPage {

View file

@ -2,18 +2,20 @@ import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn";
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings, Repeat, Receipt,
} from "lucide-react";
const NAV = [
{ href: "/", icon: LayoutDashboard, label: "Home" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions",icon: ArrowLeftRight, label: "Txns" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" },
{ href: "/", icon: LayoutDashboard, label: "Home" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions", icon: ArrowLeftRight, label: "Txns" },
{ href: "/subscriptions",icon: Repeat, label: "Recurring" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export default function MobileNav() {

View file

@ -13,15 +13,19 @@ import {
ChevronLeft,
ChevronRight,
Coins,
Repeat,
Receipt,
} from "lucide-react";
const navItems = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions", icon: ArrowLeftRight, label: "Transactions" },
{ href: "/subscriptions", icon: Repeat, label: "Subscriptions" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Investments" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/tax", icon: Receipt, label: "Tax" },
{ href: "/predictions", icon: Sparkles, label: "Predictions" },
{ href: "/settings", icon: Settings, label: "Settings" },
];

View file

@ -18,6 +18,20 @@ import { Link } from "react-router-dom";
const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"];
const TOOLTIP_STYLE = {
contentStyle: {
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
color: "hsl(var(--foreground))",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" },
itemStyle: { color: "hsl(var(--muted-foreground))" },
cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 },
};
const TYPE_COLORS: Record<string, string> = {
income: "text-success",
expense: "text-destructive",
@ -117,7 +131,7 @@ export default function Dashboard() {
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} />
<Area type="monotone" dataKey="value" stroke="hsl(var(--primary))" fill="url(#nwGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
@ -138,7 +152,7 @@ export default function Dashboard() {
<BarChart data={ieReport.points.map(p => ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}>
<XAxis dataKey="month" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="income" fill="#22c55e" radius={[2,2,0,0]} name="Income" />
<Bar dataKey="expenses" fill="#ef4444" radius={[2,2,0,0]} name="Expenses" />
</BarChart>
@ -166,7 +180,7 @@ export default function Dashboard() {
cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}>
{catReport.items.slice(0,8).map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-1.5 min-w-0">

View file

@ -10,6 +10,20 @@ const COLORS = [
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
const TOOLTIP_STYLE = {
contentStyle: {
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
color: "hsl(var(--foreground))",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" },
itemStyle: { color: "hsl(var(--muted-foreground))" },
cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 },
};
const TYPE_COLORS: Record<string, string> = {
stock: "#6366f1",
etf: "#22c55e",
@ -181,16 +195,11 @@ export function CostVsValueChart({ portfolio }: { portfolio: PortfolioSummary })
width={52}
/>
<Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number, name: string) => [
formatCurrency(value, portfolio.currency),
name === "cost" ? "Cost basis" : "Current value",
]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Bar dataKey="cost" name="cost" fill="hsl(var(--muted-foreground))" opacity={0.4} radius={[4, 4, 0, 0]} barSize={20} />
<Bar dataKey="value" name="value" radius={[4, 4, 0, 0]} barSize={20}>
@ -265,13 +274,8 @@ export function ReturnChart({ portfolio }: { portfolio: PortfolioSummary }) {
/>
<ReferenceLine x={0} stroke="hsl(var(--border))" strokeWidth={1.5} />
<Tooltip
{...TOOLTIP_STYLE}
formatter={(value: number) => [`${Number(value).toFixed(2)}%`, "Return"]}
contentStyle={{
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Bar dataKey="pct" radius={[0, 4, 4, 0]} barSize={18}>
<LabelList

View file

@ -37,6 +37,20 @@ const COLORS = [
const ASSET_COLORS = ["#22c55e", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4", "#84cc16"];
const LIABILITY_COLORS = ["#ef4444", "#f97316", "#ec4899"];
const TOOLTIP_STYLE = {
contentStyle: {
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
color: "hsl(var(--foreground))",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
labelStyle: { color: "hsl(var(--foreground))", fontWeight: 500, marginBottom: "4px" },
itemStyle: { color: "hsl(var(--muted-foreground))" },
cursor: { fill: "hsl(var(--muted-foreground))", fillOpacity: 0.08 },
};
function StatCard({ label, value, change, currency }: {
label: string; value: number; change?: number; currency: string;
}) {
@ -256,7 +270,7 @@ function NetWorthTab() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.base_currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.base_currency)} />
<Area type="monotone" dataKey="net_worth" stroke="hsl(var(--primary))" fill="url(#nwGrad)" strokeWidth={2} name="Net Worth" />
<Area type="monotone" dataKey="total_assets" stroke="#22c55e" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Assets" />
<Area type="monotone" dataKey="total_liabilities" stroke="#ef4444" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Liabilities" />
@ -290,7 +304,7 @@ function IncomeExpenseTab() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
@ -344,7 +358,7 @@ function CashFlowTab() {
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis yAxisId="bars" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<YAxis yAxisId="line" orientation="right" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(1)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} 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]} />
@ -408,7 +422,7 @@ function SavingsRateTab() {
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis yAxisId="bars" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis yAxisId="rate" orientation="right" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `${v}%`} />
<Tooltip formatter={(v: number, name: string) => name === "Savings Rate %" ? `${v.toFixed(1)}%` : formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} 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]} />
@ -488,7 +502,7 @@ function CategoriesTab() {
/>
))}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2 min-w-48">
@ -583,7 +597,7 @@ function BudgetVsActualTab() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" width={120} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="budgeted" fill="hsl(var(--primary))" name="Budgeted" radius={[0, 2, 2, 0]} />
<Bar dataKey="actual" fill="#f97316" name="Actual" radius={[0, 2, 2, 0]} />
@ -617,7 +631,7 @@ function SpendingTrendsTab() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${v}`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
{data.categories.slice(0, 8).map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={COLORS[i % COLORS.length]} />
@ -677,7 +691,7 @@ function InvestmentsTab() {
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" width={60} />
<Tooltip formatter={(v: number) => formatCurrency(v, perf.currency)} />
<Tooltip {...TOOLTIP_STYLE} 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"} />

View file

@ -0,0 +1,269 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSubscriptions, triggerDetection } from "@/api/subscriptions";
import { updateTransaction } from "@/api/transactions";
import type { Subscription } from "@/api/subscriptions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format, parseISO, differenceInDays } from "date-fns";
import {
RefreshCw, Loader2, CalendarClock, AlertCircle, Ban,
} from "lucide-react";
type SortKey = "next" | "amount" | "name";
function nextBadge(nextExpected: string | null) {
if (!nextExpected) return null;
const days = differenceInDays(parseISO(nextExpected), new Date());
if (days < 0) {
return (
<span className="flex items-center gap-1 text-xs font-medium text-destructive bg-destructive/10 border border-destructive/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<AlertCircle className="w-3 h-3" /> Overdue
</span>
);
}
if (days === 0) {
return (
<span className="flex items-center gap-1 text-xs font-medium text-warning bg-warning/10 border border-warning/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<CalendarClock className="w-3 h-3" /> Today
</span>
);
}
if (days <= 7) {
return (
<span className="flex items-center gap-1 text-xs font-medium text-warning bg-warning/10 border border-warning/20 rounded-full px-2 py-0.5 whitespace-nowrap">
<CalendarClock className="w-3 h-3" /> {days}d
</span>
);
}
return (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{format(parseISO(nextExpected), "d MMM")}
</span>
);
}
function FrequencyBadge({ frequency }: { frequency: string }) {
const labels: Record<string, string> = {
weekly: "Weekly",
fortnightly: "Fortnightly",
monthly: "Monthly",
quarterly: "Quarterly",
yearly: "Yearly",
unknown: "Unknown",
};
return (
<span className="text-xs bg-secondary text-muted-foreground rounded-full px-2 py-0.5">
{labels[frequency] ?? frequency}
</span>
);
}
function SubscriptionRow({
sub,
onUnmark,
}: {
sub: Subscription;
onUnmark: (id: string) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="flex items-center gap-4 px-4 py-3 border-b border-border/50 last:border-0 hover:bg-secondary/20 transition-colors group">
{/* Icon */}
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<RefreshCw className="w-4 h-4 text-primary" />
</div>
{/* Name + account */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{sub.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<FrequencyBadge frequency={sub.frequency} />
{sub.account_name && (
<span className="text-xs text-muted-foreground truncate">{sub.account_name}</span>
)}
{sub.manually_set && (
<span className="text-xs text-muted-foreground italic">manual</span>
)}
</div>
</div>
{/* Last paid */}
<div className="hidden md:block text-xs text-muted-foreground text-right shrink-0">
<p className="text-muted-foreground/70">Last paid</p>
<p>{sub.last_paid ? format(parseISO(sub.last_paid), "d MMM yyyy") : "—"}</p>
</div>
{/* Next expected */}
<div className="hidden sm:flex flex-col items-end gap-0.5 shrink-0">
<p className="text-xs text-muted-foreground/70">Next</p>
{nextBadge(sub.next_expected)}
</div>
{/* Amount */}
<div className="text-right shrink-0">
<p className="font-semibold tabular-nums text-destructive">
{formatCurrency(Math.abs(sub.amount), "GBP")}
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(sub.monthly_equivalent, "GBP")}/mo
</p>
</div>
{/* Actions */}
<div className="relative shrink-0">
<button
onClick={() => setMenuOpen((o) => !o)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded hover:bg-secondary text-muted-foreground"
>
<span className="text-lg leading-none">···</span>
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-8 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-44">
<button
onClick={() => { setMenuOpen(false); onUnmark(sub.latest_transaction_id); }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-secondary/50 transition-colors"
>
<Ban className="w-4 h-4" /> Mark as not recurring
</button>
</div>
</>
)}
</div>
</div>
);
}
export default function SubscriptionsPage() {
const qc = useQueryClient();
const [sort, setSort] = useState<SortKey>("next");
const [rescanMsg, setRescanMsg] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["subscriptions"],
queryFn: getSubscriptions,
});
const rescanMutation = useMutation({
mutationFn: triggerDetection,
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ["subscriptions"] });
qc.invalidateQueries({ queryKey: ["transactions"] });
setRescanMsg(`Done — ${result.newly_tagged} newly tagged, ${result.total_recurring} total recurring.`);
setTimeout(() => setRescanMsg(null), 4000);
},
});
const unmarkMutation = useMutation({
mutationFn: (txnId: string) =>
updateTransaction(txnId, {
is_recurring: false,
recurring_rule: { manually_set: true },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["subscriptions"] });
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
const sorted = [...(data?.subscriptions ?? [])].sort((a, b) => {
if (sort === "next") {
return (a.next_expected ?? "9999") < (b.next_expected ?? "9999") ? -1 : 1;
}
if (sort === "amount") return Math.abs(b.amount) - Math.abs(a.amount);
return a.name.localeCompare(b.name);
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-sm text-muted-foreground mt-1">
Direct debits, standing orders, and recurring payments
</p>
</div>
<button
onClick={() => rescanMutation.mutate()}
disabled={rescanMutation.isPending}
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary disabled:opacity-50 transition-colors shrink-0"
>
{rescanMutation.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <RefreshCw className="w-4 h-4" />}
Re-scan
</button>
</div>
{rescanMsg && (
<div className="bg-primary/10 border border-primary/20 rounded-lg px-4 py-2.5 text-sm text-primary">
{rescanMsg}
</div>
)}
{/* Summary card */}
{data && (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-xs text-muted-foreground mb-1">Estimated monthly recurring spend</p>
<p className="text-3xl font-bold tabular-nums text-destructive">
{formatCurrency(data.total_monthly_equivalent, data.currency)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{data.subscriptions.length} recurring payment{data.subscriptions.length !== 1 ? "s" : ""} detected
</p>
</div>
)}
{/* Sort */}
{(data?.subscriptions.length ?? 0) > 0 && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Sort:</span>
{(["next", "amount", "name"] as SortKey[]).map((key) => (
<button
key={key}
onClick={() => setSort(key)}
className={cn(
"px-3 py-1 rounded-lg border text-xs font-medium transition-colors",
sort === key
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{key === "next" ? "Next payment" : key === "amount" ? "Amount" : "Name"}
</button>
))}
</div>
)}
{/* List */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="space-y-px">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-secondary/30 animate-pulse" />
))}
</div>
) : sorted.length === 0 ? (
<div className="py-16 text-center text-muted-foreground space-y-3">
<RefreshCw className="w-10 h-10 mx-auto opacity-20" />
<p className="font-medium">No recurring transactions detected</p>
<p className="text-sm">Import a few months of bank statements, then hit Re-scan.</p>
</div>
) : (
sorted.map((sub) => (
<SubscriptionRow
key={`${sub.name}|${sub.amount}|${sub.frequency}`}
sub={sub}
onUnmark={(id) => unmarkMutation.mutate(id)}
/>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,245 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Pencil, Trash2, Info } from "lucide-react";
import {
TAX_QUERY_KEYS,
createCgtDisposal,
updateCgtDisposal,
deleteCgtDisposal,
type TaxReport,
type ManualDisposal,
type ManualDisposalCreate,
} from "@/api/tax";
import ManualDisposalFormModal from "./ManualDisposalFormModal";
function gbp(v: string | number) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
}
function pct(r: number) {
return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`;
}
function GainCell({ v }: { v: string }) {
const n = Number(v);
return (
<span className={`tabular-nums ${n > 0 ? "text-green-500" : n < 0 ? "text-destructive" : ""}`}>
{gbp(v)}
</span>
);
}
interface Props {
taxYear: number;
report: TaxReport;
}
export default function CGTSection({ taxYear, report }: Props) {
const qc = useQueryClient();
const cgt = report.cgt;
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<ManualDisposal | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
function invalidate() {
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) });
}
const createMut = useMutation({
mutationFn: (data: ManualDisposalCreate) => createCgtDisposal(taxYear, data),
onSuccess: () => { invalidate(); setShowAdd(false); },
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<ManualDisposalCreate> }) =>
updateCgtDisposal(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteCgtDisposal(id),
onSuccess: () => { invalidate(); setDeletingId(null); },
});
return (
<>
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
<h2 className="text-base font-semibold">Capital Gains Tax</h2>
{/* Mid-year rate change note for 2024/25 */}
{taxYear === 2025 && (
<div className="flex items-start gap-2 rounded-md border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>
<strong>2024/25 note:</strong> The October 2024 Budget raised CGT rates mid-year (from 30 Oct 2024: 18% basic, 24% higher, up from 10%/20%). This tool uses a single set of rates for the whole year disposals before and after 30 Oct may be blended. Verify any significant gains against HMRC guidance.
</span>
</div>
)}
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Gross Gain", value: cgt.gross_gain },
{ label: "Annual Exempt", value: cgt.exempt },
{ label: "Taxable Gain", value: cgt.taxable_gain },
{ label: "CGT Liability", value: cgt.liability, bold: true },
].map(({ label, value, bold }) => (
<div key={label} className="rounded-md bg-secondary/40 px-4 py-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm tabular-nums ${bold ? "font-semibold" : ""}`}>{gbp(value)}</p>
</div>
))}
</div>
{/* Band breakdown */}
{cgt.band_breakdown.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Band Breakdown</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.band_breakdown.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Auto-detected disposals */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Investment Disposals (auto-detected)
</p>
{cgt.investment_disposals.length === 0 ? (
<p className="text-sm text-muted-foreground">No investment disposals detected for this tax year.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Proceeds</th>
<th className="text-right pb-2 font-normal">Cost Basis</th>
<th className="text-right pb-2 font-normal">Gain / Loss</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.investment_disposals.map((d, i) => (
<tr key={i} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(d.date)}</td>
<td className="py-2">
<p className="font-medium">{d.asset}</p>
<p className="text-xs text-muted-foreground">{d.symbol} · {d.quantity} units</p>
</td>
<td className="py-2 text-right tabular-nums">{gbp(d.proceeds)}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.cost_basis)}</td>
<td className="py-2 text-right"><GainCell v={d.gain_loss} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Manual disposals */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manual Disposals
</p>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
{cgt.manual_disposals.length === 0 ? (
<p className="text-sm text-muted-foreground">No manual disposals add any non-investment capital gains here (e.g. property).</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Proceeds</th>
<th className="text-right pb-2 font-normal">Cost</th>
<th className="text-right pb-2 font-normal">Gain / Loss</th>
<th className="text-right pb-2 font-normal">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{cgt.manual_disposals.map(d => (
<tr key={d.id} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(d.disposal_date)}</td>
<td className="py-2 font-medium">{d.asset_description}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.proceeds)}</td>
<td className="py-2 text-right tabular-nums">{gbp(d.cost_basis)}</td>
<td className="py-2 text-right"><GainCell v={d.gain_loss} /></td>
<td className="py-2 text-right">
{deletingId === d.id ? (
<span className="flex items-center justify-end gap-2 text-xs">
<span className="text-muted-foreground">Delete?</span>
<button onClick={() => deleteMut.mutate(d.id)} className="text-destructive hover:underline">Yes</button>
<button onClick={() => setDeletingId(null)} className="text-muted-foreground hover:underline">No</button>
</span>
) : (
<span className="flex items-center justify-end gap-2">
<button onClick={() => setEditing(d)} className="text-muted-foreground hover:text-foreground transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingId(d.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{showAdd && (
<ManualDisposalFormModal
onClose={() => setShowAdd(false)}
onSubmit={data => createMut.mutate(data)}
isLoading={createMut.isPending}
/>
)}
{editing && (
<ManualDisposalFormModal
disposal={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMut.mutate({ id: editing.id, data })}
isLoading={updateMut.isPending}
/>
)}
</>
);
}

View file

@ -0,0 +1,101 @@
import { type TaxReport } from "@/api/tax";
function gbp(v: string) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
function pct(r: number) {
return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
}
interface Props {
report: TaxReport;
}
export default function DividendSection({ report }: Props) {
const div = report.dividends;
return (
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
<h2 className="text-base font-semibold">Dividends</h2>
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Gross Dividends", value: div.gross_dividends },
{ label: "Allowance", value: div.allowance },
{ label: "Taxable", value: div.taxable_dividends },
{ label: "Liability", value: div.liability, bold: true },
].map(({ label, value, bold }) => (
<div key={label} className="rounded-md bg-secondary/40 px-4 py-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm tabular-nums ${bold ? "font-semibold" : ""}`}>{gbp(value)}</p>
</div>
))}
</div>
{/* Band breakdown */}
{div.band_breakdown.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Band Breakdown</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{div.band_breakdown.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Dividend transactions */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Dividend Transactions (auto-detected)
</p>
{div.dividend_transactions.length === 0 ? (
<p className="text-sm text-muted-foreground">No dividend income detected for this tax year.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal">Date</th>
<th className="text-left pb-2 font-normal">Asset</th>
<th className="text-right pb-2 font-normal">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{div.dividend_transactions.map((t, i) => (
<tr key={i} className="hover:bg-secondary/30 transition-colors">
<td className="py-2 text-muted-foreground">{fmtDate(t.date)}</td>
<td className="py-2">
<p className="font-medium">{t.asset}</p>
<p className="text-xs text-muted-foreground">{t.symbol}</p>
</td>
<td className="py-2 text-right tabular-nums text-green-500">{gbp(t.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
import { useState } from "react";
import { X, Loader2 } from "lucide-react";
import { type ManualDisposal, type ManualDisposalCreate } from "@/api/tax";
interface Props {
disposal?: ManualDisposal;
onClose: () => void;
onSubmit: (data: ManualDisposalCreate) => void;
isLoading: boolean;
}
export default function ManualDisposalFormModal({ disposal, onClose, onSubmit, isLoading }: Props) {
const isEdit = !!disposal;
const [form, setForm] = useState({
disposal_date: disposal?.disposal_date ?? "",
asset_description: disposal?.asset_description ?? "",
proceeds: disposal ? String(Number(disposal.proceeds)) : "",
cost_basis: disposal ? String(Number(disposal.cost_basis)) : "",
notes: disposal?.notes ?? "",
});
const [error, setError] = useState<string | null>(null);
function set(key: string, value: string) {
setForm(f => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.disposal_date) { setError("Disposal date is required"); return; }
if (!form.asset_description.trim()) { setError("Asset description is required"); return; }
const proceeds = parseFloat(form.proceeds);
const cost = parseFloat(form.cost_basis);
if (isNaN(proceeds) || proceeds < 0) { setError("Enter valid proceeds"); return; }
if (isNaN(cost) || cost < 0) { setError("Enter valid cost basis"); return; }
setError(null);
onSubmit({
disposal_date: form.disposal_date,
asset_description: form.asset_description.trim(),
proceeds,
cost_basis: cost,
notes: form.notes.trim() || null,
});
}
const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Disposal" : "Add Manual Disposal"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Disposal Date</label>
<input type="date" value={form.disposal_date} onChange={e => set("disposal_date", e.target.value)} className={inp} />
</div>
<div className="col-span-2 sm:col-span-1">
<label className="text-sm font-medium block mb-1.5">Asset Description</label>
<input value={form.asset_description} onChange={e => set("asset_description", e.target.value)} className={inp} placeholder="e.g. Rental property" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Proceeds (£)</label>
<input type="number" step="0.01" value={form.proceeds} onChange={e => set("proceeds", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Cost Basis (£)</label>
<input type="number" step="0.01" value={form.cost_basis} onChange={e => set("cost_basis", e.target.value)} className={inp} placeholder="0.00" />
</div>
</div>
{form.proceeds && form.cost_basis && !isNaN(parseFloat(form.proceeds)) && !isNaN(parseFloat(form.cost_basis)) && (
<p className="text-sm text-muted-foreground">
Gain / Loss:{" "}
<span className={parseFloat(form.proceeds) - parseFloat(form.cost_basis) >= 0 ? "text-green-500" : "text-destructive"}>
{new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(
parseFloat(form.proceeds) - parseFloat(form.cost_basis)
)}
</span>
</p>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<input value={form.notes} onChange={e => set("notes", e.target.value)} className={inp} placeholder="Optional" />
</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-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Add Disposal"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
import { type TaxReport } from "@/api/tax";
import { taxYearDisplay } from "./TaxYearSelector";
function gbp(v: string) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
interface Props {
report: TaxReport;
}
export default function OverallLiabilityCard({ report }: Props) {
const s = report.summary;
const net = Number(s.net_owed);
const isZero = net === 0;
const netColor = isZero
? "text-muted-foreground"
: s.overpaid
? "text-green-500"
: "text-yellow-500";
const netLabel = isZero ? "Balanced" : s.overpaid ? "Refund due" : "Additional tax owed";
const breakdown = [
{ label: "Income Tax", value: report.income_tax.liability },
{ label: "National Insurance", value: report.ni.liability },
{ label: "Capital Gains Tax", value: report.cgt.liability },
{ label: "Dividend Tax", value: report.dividends.liability },
];
return (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-6">Overall Tax Position {taxYearDisplay(report.tax_year)}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 items-start">
{/* Big numbers */}
<div className="sm:col-span-2 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-secondary/40 p-4">
<p className="text-xs text-muted-foreground mb-1">Total Liability</p>
<p className="text-2xl font-bold tabular-nums">{gbp(s.total_liability)}</p>
</div>
<div className="rounded-lg bg-secondary/40 p-4">
<p className="text-xs text-muted-foreground mb-1">Already Withheld</p>
<p className="text-2xl font-bold tabular-nums">{gbp(s.total_withheld)}</p>
</div>
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-4 col-span-2">
<p className={`text-xs mb-1 font-medium ${netColor}`}>{netLabel}</p>
<p className={`text-3xl font-bold tabular-nums ${netColor}`}>{gbp(s.net_owed)}</p>
{!isZero && (
<p className="text-xs text-muted-foreground mt-1">
{s.overpaid
? "HMRC should refund this amount (verify via self-assessment or payroll)"
: "You may owe this via self-assessment — verify with HMRC"}
</p>
)}
</div>
</div>
{/* Liability breakdown */}
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Breakdown</p>
{breakdown.map(({ label, value }) => (
<div key={label} className="flex justify-between items-baseline">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm tabular-nums font-medium">{gbp(value)}</span>
</div>
))}
<div className="flex justify-between items-baseline pt-2 border-t border-border">
<span className="text-sm font-semibold">Total</span>
<span className="text-sm font-semibold tabular-nums">{gbp(s.total_liability)}</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { X, Loader2, AlertTriangle } from "lucide-react";
import { type P60Entry } from "@/api/tax";
interface Props {
existingCount: number;
onClose: () => void;
onSubmit: (data: P60Entry) => void;
isLoading: boolean;
}
export default function P60Modal({ existingCount, onClose, onSubmit, isLoading }: Props) {
const [form, setForm] = useState({
gross_pay: "",
income_tax_withheld: "",
ni_withheld: "",
net_pay: "",
});
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState<string | null>(null);
function set(key: string, value: string) {
setForm(f => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (existingCount > 0 && !confirmed) { setError("Please confirm you want to replace existing payslips"); return; }
const gross = parseFloat(form.gross_pay);
const tax = parseFloat(form.income_tax_withheld);
const ni = parseFloat(form.ni_withheld);
const net = parseFloat(form.net_pay);
if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay"); return; }
if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax"); return; }
if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; }
if (isNaN(net) || net < 0) { setError("Enter a valid net pay"); return; }
setError(null);
onSubmit({ gross_pay: gross, income_tax_withheld: tax, ni_withheld: ni, net_pay: net });
}
const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">Enter P60 Totals</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Enter the year-end totals from your P60. These are cumulative figures for the full tax year.
</p>
{existingCount > 0 && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
This will permanently replace your {existingCount} existing payslip{existingCount !== 1 ? "s" : ""} for this tax year with a single P60 entry.
</span>
</div>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Total Gross Pay (£)</label>
<input type="number" step="0.01" value={form.gross_pay} onChange={e => set("gross_pay", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Income Tax Withheld (£)</label>
<input type="number" step="0.01" value={form.income_tax_withheld} onChange={e => set("income_tax_withheld", e.target.value)} className={inp} placeholder="0.00" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">NI Withheld (£)</label>
<input type="number" step="0.01" value={form.ni_withheld} onChange={e => set("ni_withheld", e.target.value)} className={inp} placeholder="0.00" />
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Net Pay (£)</label>
<input type="number" step="0.01" value={form.net_pay} onChange={e => set("net_pay", e.target.value)} className={inp} placeholder="0.00" />
</div>
{existingCount > 0 && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="rounded"
/>
<span className="text-sm">I understand the existing payslips will be deleted</span>
</label>
)}
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Enter P60
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { useState } from "react";
import { X, Loader2 } from "lucide-react";
import { type Payslip, type PayslipCreate } from "@/api/tax";
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
interface Props {
taxYear: number;
payslip?: Payslip;
onClose: () => void;
onSubmit: (data: PayslipCreate) => void;
isLoading: boolean;
}
export default function PayslipFormModal({ taxYear, payslip, onClose, onSubmit, isLoading }: Props) {
const isEdit = !!payslip;
const [form, setForm] = useState({
period_month: payslip?.period_month ?? 4,
period_year: payslip?.period_year ?? taxYear - 1,
gross_pay: payslip ? payslip.gross_pay : "",
income_tax_withheld: payslip ? payslip.income_tax_withheld : "",
ni_withheld: payslip ? payslip.ni_withheld : "",
net_pay: payslip ? payslip.net_pay : "",
notes: payslip?.notes ?? "",
});
const [error, setError] = useState<string | null>(null);
function set(key: string, value: string | number) {
setForm(f => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const gross = parseFloat(form.gross_pay);
const tax = parseFloat(form.income_tax_withheld);
const ni = parseFloat(form.ni_withheld);
const net = parseFloat(form.net_pay);
if (isNaN(gross) || gross < 0) { setError("Enter a valid gross pay amount"); return; }
if (isNaN(tax) || tax < 0) { setError("Enter a valid income tax amount"); return; }
if (isNaN(ni) || ni < 0) { setError("Enter a valid NI amount"); return; }
if (isNaN(net) || net < 0) { setError("Enter a valid net pay amount"); return; }
setError(null);
onSubmit({
period_month: Number(form.period_month),
period_year: Number(form.period_year),
gross_pay: gross,
income_tax_withheld: tax,
ni_withheld: ni,
net_pay: net,
notes: form.notes.trim() || null,
});
}
const inp = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Payslip" : "Add Payslip"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Month</label>
<select value={form.period_month} onChange={e => set("period_month", e.target.value)} className={inp}>
{MONTHS.map((m, i) => (
<option key={i + 1} value={i + 1}>{m}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Year</label>
<input
type="number"
value={form.period_year}
onChange={e => set("period_year", e.target.value)}
className={inp}
placeholder="2024"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Gross Pay (£)</label>
<input
type="number" step="0.01"
value={form.gross_pay}
onChange={e => set("gross_pay", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Income Tax Withheld (£)</label>
<input
type="number" step="0.01"
value={form.income_tax_withheld}
onChange={e => set("income_tax_withheld", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">NI Withheld (£)</label>
<input
type="number" step="0.01"
value={form.ni_withheld}
onChange={e => set("ni_withheld", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Net Pay (£)</label>
<input
type="number" step="0.01"
value={form.net_pay}
onChange={e => set("net_pay", e.target.value)}
className={inp}
placeholder="0.00"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<input
value={form.notes}
onChange={e => set("notes", e.target.value)}
className={inp}
placeholder="Optional"
/>
</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-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Add Payslip"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,233 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, FileText, Pencil, Trash2, Loader2 } from "lucide-react";
import {
TAX_QUERY_KEYS,
getPayslips,
createPayslip,
updatePayslip,
deletePayslip,
enterP60,
type Payslip,
type PayslipCreate,
type P60Entry,
} from "@/api/tax";
import PayslipFormModal from "./PayslipFormModal";
import P60Modal from "./P60Modal";
const MONTH_ABBR = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
function gbp(v: string) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
function monthLabel(p: Payslip) {
if (p.is_p60) return "P60";
if (p.period_month) return `${MONTH_ABBR[p.period_month - 1]} ${p.period_year}`;
return `${p.period_year}`;
}
interface Props {
taxYear: number;
}
export default function PayslipTable({ taxYear }: Props) {
const qc = useQueryClient();
const { data: payslips = [], isLoading } = useQuery({
queryKey: TAX_QUERY_KEYS.payslips(taxYear),
queryFn: () => getPayslips(taxYear),
});
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<Payslip | null>(null);
const [showP60, setShowP60] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
function invalidate() {
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.payslips(taxYear) });
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) });
}
const createMut = useMutation({
mutationFn: (data: PayslipCreate) => createPayslip(taxYear, data),
onSuccess: () => { invalidate(); setShowAdd(false); },
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<PayslipCreate> }) => updatePayslip(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
});
const deleteMut = useMutation({
mutationFn: (id: string) => deletePayslip(id),
onSuccess: () => { invalidate(); setDeletingId(null); },
});
const p60Mut = useMutation({
mutationFn: (data: P60Entry) => enterP60(taxYear, data),
onSuccess: () => { invalidate(); setShowP60(false); },
});
// Totals
const totals = payslips.reduce(
(acc, p) => ({
gross: acc.gross + Number(p.gross_pay),
tax: acc.tax + Number(p.income_tax_withheld),
ni: acc.ni + Number(p.ni_withheld),
net: acc.net + Number(p.net_pay),
}),
{ gross: 0, tax: 0, ni: 0, net: 0 }
);
function fmtNum(n: number) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(n);
}
return (
<>
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-base font-semibold">Payslips</h2>
<div className="flex gap-2">
<button
onClick={() => setShowP60(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
>
<FileText className="w-4 h-4" />
Enter P60
</button>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Payslip
</button>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading
</div>
) : payslips.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
No payslips yet add a payslip or enter P60 totals.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs uppercase tracking-wide">
<th className="px-6 py-3 text-left">Period</th>
<th className="px-4 py-3 text-right">Gross</th>
<th className="px-4 py-3 text-right">Income Tax</th>
<th className="px-4 py-3 text-right">NI</th>
<th className="px-4 py-3 text-right">Net Pay</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{payslips.map(p => (
<tr key={p.id} className="hover:bg-secondary/30 transition-colors">
<td className="px-6 py-3 font-medium">
{monthLabel(p)}
{p.is_p60 && (
<span className="ml-2 text-xs bg-primary/15 text-primary px-1.5 py-0.5 rounded">P60</span>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.gross_pay)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.income_tax_withheld)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.ni_withheld)}</td>
<td className="px-4 py-3 text-right tabular-nums">{gbp(p.net_pay)}</td>
<td className="px-4 py-3 text-right">
{deletingId === p.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">Delete?</span>
<button
onClick={() => deleteMut.mutate(p.id)}
disabled={deleteMut.isPending}
className="text-xs text-destructive hover:underline"
>
Yes
</button>
<button
onClick={() => setDeletingId(null)}
className="text-xs text-muted-foreground hover:underline"
>
No
</button>
</div>
) : (
<div className="flex items-center justify-end gap-2">
{!p.is_p60 && (
<button
onClick={() => setEditing(p)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
)}
<button
onClick={() => setDeletingId(p.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
{payslips.length > 1 && (
<tfoot>
<tr className="border-t-2 border-border font-semibold text-xs">
<td className="px-6 py-3 text-muted-foreground uppercase tracking-wide">Total</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.gross)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.tax)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.ni)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(totals.net)}</td>
<td />
</tr>
</tfoot>
)}
</table>
</div>
)}
</div>
{showAdd && (
<PayslipFormModal
taxYear={taxYear}
onClose={() => setShowAdd(false)}
onSubmit={data => createMut.mutate(data)}
isLoading={createMut.isPending}
/>
)}
{editing && (
<PayslipFormModal
taxYear={taxYear}
payslip={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMut.mutate({ id: editing.id, data })}
isLoading={updateMut.isPending}
/>
)}
{showP60 && (
<P60Modal
existingCount={payslips.length}
onClose={() => setShowP60(false)}
onSubmit={data => p60Mut.mutate(data)}
isLoading={p60Mut.isPending}
/>
)}
</>
);
}

View file

@ -0,0 +1,440 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X, Plus, Trash2, Loader2 } from "lucide-react";
import {
TAX_QUERY_KEYS,
getRateConfig,
upsertRateConfig,
type TaxRateConfigUpdate,
} from "@/api/tax";
import { taxYearDisplay } from "./TaxYearSelector";
// ---------------------------------------------------------------------------
// Types for local form state — all numeric values kept as strings for inputs
// ---------------------------------------------------------------------------
type Tab = "income_tax" | "ni" | "cgt" | "dividend";
type BandRow = { from: string; to: string; rate: string };
type CgtForm = { exempt: string; basic_rate: string; higher_rate: string };
type DivForm = {
allowance: string;
basic_rate: string;
higher_rate: string;
additional_rate: string;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pctIn(d: number) {
// Convert stored decimal (0.20) → display percentage string ("20")
const p = d * 100;
return p % 1 === 0 ? String(p) : p.toFixed(4).replace(/\.?0+$/, "");
}
function parseBands(rows: BandRow[]) {
return rows.map(r => ({
from: parseFloat(r.from) || 0,
to: r.to.trim() === "" ? null : parseFloat(r.to),
rate: (parseFloat(r.rate) || 0) / 100,
}));
}
// ---------------------------------------------------------------------------
// Band table sub-component
// ---------------------------------------------------------------------------
function BandTable({
bands,
onChange,
}: {
bands: BandRow[];
onChange: (rows: BandRow[]) => void;
}) {
const inp =
"w-full rounded border border-input bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring tabular-nums";
function update(i: number, field: keyof BandRow, val: string) {
const next = bands.map((b, idx) => (idx === i ? { ...b, [field]: val } : b));
onChange(next);
}
function addRow() {
onChange([...bands, { from: "", to: "", rate: "" }]);
}
function removeRow(i: number) {
onChange(bands.filter((_, idx) => idx !== i));
}
return (
<div className="space-y-2">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-xs border-b border-border">
<th className="text-left pb-2 font-normal pr-2">From (£)</th>
<th className="text-left pb-2 font-normal pr-2">To (£, blank = unlimited)</th>
<th className="text-left pb-2 font-normal pr-2">Rate (%)</th>
<th className="w-8" />
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{bands.map((b, i) => (
<tr key={i}>
<td className="py-1.5 pr-2">
<input
type="number"
value={b.from}
onChange={e => update(i, "from", e.target.value)}
className={inp}
placeholder="0"
/>
</td>
<td className="py-1.5 pr-2">
<input
type="number"
value={b.to}
onChange={e => update(i, "to", e.target.value)}
className={inp}
placeholder="—"
/>
</td>
<td className="py-1.5 pr-2">
<input
type="number"
step="0.01"
value={b.rate}
onChange={e => update(i, "rate", e.target.value)}
className={inp}
placeholder="0"
/>
</td>
<td className="py-1.5 text-right">
<button
type="button"
onClick={() => removeRow(i)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={addRow}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus className="w-3.5 h-3.5" />
Add band
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Main modal
// ---------------------------------------------------------------------------
const TABS: { key: Tab; label: string }[] = [
{ key: "income_tax", label: "Income Tax" },
{ key: "ni", label: "NI" },
{ key: "cgt", label: "CGT" },
{ key: "dividend", label: "Dividends" },
];
interface Props {
taxYear: number;
onClose: () => void;
}
export default function RateConfigModal({ taxYear, onClose }: Props) {
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>("income_tax");
const [itBands, setItBands] = useState<BandRow[]>([]);
const [niBands, setNiBands] = useState<BandRow[]>([]);
const [cgt, setCgt] = useState<CgtForm>({ exempt: "", basic_rate: "", higher_rate: "" });
const [div, setDiv] = useState<DivForm>({
allowance: "",
basic_rate: "",
higher_rate: "",
additional_rate: "",
});
const [error, setError] = useState<string | null>(null);
const { data: config, isLoading } = useQuery({
queryKey: TAX_QUERY_KEYS.rateConfig(taxYear),
queryFn: () => getRateConfig(taxYear),
});
// Populate form state once config loads
useEffect(() => {
if (!config) return;
const r = config.rates;
if (r.income_tax?.bands) {
setItBands(
r.income_tax.bands.map(b => ({
from: String(b.from),
to: b.to === null ? "" : String(b.to),
rate: pctIn(b.rate),
}))
);
}
if (r.ni?.bands) {
setNiBands(
r.ni.bands.map(b => ({
from: String(b.from),
to: b.to === null ? "" : String(b.to),
rate: pctIn(b.rate),
}))
);
}
if (r.cgt) {
setCgt({
exempt: String(r.cgt.exempt),
basic_rate: pctIn(r.cgt.basic_rate),
higher_rate: pctIn(r.cgt.higher_rate),
});
}
if (r.dividend) {
setDiv({
allowance: String(r.dividend.allowance),
basic_rate: pctIn(r.dividend.basic_rate),
higher_rate: pctIn(r.dividend.higher_rate),
additional_rate: pctIn(r.dividend.additional_rate),
});
}
}, [config]);
const saveMut = useMutation({
mutationFn: (data: TaxRateConfigUpdate) => upsertRateConfig(taxYear, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.rateConfig(taxYear) });
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) });
onClose();
},
onError: (err: unknown) => {
setError((err as Error)?.message ?? "Failed to save");
},
});
function handleSave() {
setError(null);
const payload: TaxRateConfigUpdate = {
income_tax: itBands.length > 0 ? { bands: parseBands(itBands) } : undefined,
ni: niBands.length > 0 ? { bands: parseBands(niBands) } : undefined,
cgt: {
exempt: parseFloat(cgt.exempt) || 0,
basic_rate: (parseFloat(cgt.basic_rate) || 0) / 100,
higher_rate: (parseFloat(cgt.higher_rate) || 0) / 100,
},
dividend: {
allowance: parseFloat(div.allowance) || 0,
basic_rate: (parseFloat(div.basic_rate) || 0) / 100,
higher_rate: (parseFloat(div.higher_rate) || 0) / 100,
additional_rate: (parseFloat(div.additional_rate) || 0) / 100,
},
};
saveMut.mutate(payload);
}
const inp =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold">
Rate Configuration {taxYearDisplay(taxYear)}
</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border shrink-0 px-6">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={[
"px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
tab === t.key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
{t.label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Loading
</div>
) : (
<>
{tab === "income_tax" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Bands are applied to gross income. The first band (0%) covers the personal
allowance; its upper limit is overridden by the tax code.
</p>
<BandTable bands={itBands} onChange={setItBands} />
</div>
)}
{tab === "ni" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Primary Class 1 NI bands. Rates are applied to gross earnings.
</p>
<BandTable bands={niBands} onChange={setNiBands} />
</div>
)}
{tab === "cgt" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Annual Exempt Amount (£)</label>
<input
type="number"
step="1"
value={cgt.exempt}
onChange={e => setCgt(c => ({ ...c, exempt: e.target.value }))}
className={inp}
placeholder="3000"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium block mb-1.5">Basic Rate (%)</label>
<input
type="number"
step="0.01"
value={cgt.basic_rate}
onChange={e => setCgt(c => ({ ...c, basic_rate: e.target.value }))}
className={inp}
placeholder="18"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Higher Rate (%)</label>
<input
type="number"
step="0.01"
value={cgt.higher_rate}
onChange={e => setCgt(c => ({ ...c, higher_rate: e.target.value }))}
className={inp}
placeholder="24"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Basic rate applies when remaining basic-rate band &gt; 0; higher rate applies to gains above.
</p>
</div>
)}
{tab === "dividend" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Annual Dividend Allowance (£)</label>
<input
type="number"
step="1"
value={div.allowance}
onChange={e => setDiv(d => ({ ...d, allowance: e.target.value }))}
className={inp}
placeholder="500"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium block mb-1.5">Basic Rate (%)</label>
<input
type="number"
step="0.01"
value={div.basic_rate}
onChange={e => setDiv(d => ({ ...d, basic_rate: e.target.value }))}
className={inp}
placeholder="8.75"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Higher Rate (%)</label>
<input
type="number"
step="0.01"
value={div.higher_rate}
onChange={e => setDiv(d => ({ ...d, higher_rate: e.target.value }))}
className={inp}
placeholder="33.75"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Additional Rate (%)</label>
<input
type="number"
step="0.01"
value={div.additional_rate}
onChange={e => setDiv(d => ({ ...d, additional_rate: e.target.value }))}
className={inp}
placeholder="39.35"
/>
</div>
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
{error && (
<p className="px-6 text-sm text-destructive bg-destructive/10 py-2 border-t border-border shrink-0">
{error}
</p>
)}
<div className="flex gap-3 px-6 py-4 border-t border-border shrink-0">
<button
type="button"
onClick={onClose}
className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saveMut.isPending || isLoading}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saveMut.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,99 @@
import { type TaxReport, type BandBreakdown } from "@/api/tax";
function gbp(v: string) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
function pct(r: number) {
return `${(r * 100 % 1 === 0 ? (r * 100).toFixed(0) : (r * 100).toFixed(1))}%`;
}
function OwedRow({ owed, overpaid }: { owed: string; overpaid?: boolean }) {
const n = Number(owed);
const isZero = n === 0;
const color = isZero
? "text-muted-foreground"
: overpaid !== undefined
? overpaid ? "text-green-500" : "text-yellow-500"
: n < 0 ? "text-green-500" : "text-yellow-500";
return (
<div className="flex justify-between items-baseline mt-1 pt-2 border-t border-border">
<span className="text-xs text-muted-foreground">
{n < 0 || (n >= 0 && !isZero && overpaid) ? "Overpaid" : n === 0 ? "Balanced" : "Still owed"}
</span>
<span className={`text-sm font-semibold tabular-nums ${color}`}>{gbp(owed)}</span>
</div>
);
}
function Kv({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className="flex justify-between items-baseline">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={`text-sm tabular-nums ${bold ? "font-semibold text-foreground" : "text-foreground"}`}>{value}</span>
</div>
);
}
function BandTable({ bands }: { bands: BandBreakdown[] }) {
if (bands.length === 0) return <p className="text-xs text-muted-foreground mt-2">No taxable amount in this category.</p>;
return (
<table className="w-full text-xs mt-2">
<thead>
<tr className="text-muted-foreground border-b border-border">
<th className="text-left pb-1 font-normal">Rate</th>
<th className="text-right pb-1 font-normal">Taxable</th>
<th className="text-right pb-1 font-normal">Tax</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{bands.map((b, i) => (
<tr key={i}>
<td className="py-1 text-muted-foreground">{pct(b.rate)}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.taxable))}</td>
<td className="py-1 text-right tabular-nums">{gbp(String(b.tax))}</td>
</tr>
))}
</tbody>
</table>
);
}
interface Props {
report: TaxReport;
}
export default function TaxNISummaryCard({ report }: Props) {
const it = report.income_tax;
const ni = report.ni;
return (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-4">Income Tax & National Insurance</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Income Tax */}
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Income Tax</p>
<Kv label="Gross income" value={gbp(report.income.gross_income)} />
<Kv label="Personal allowance" value={`${gbp(it.personal_allowance)}`} />
<Kv label="Taxable income" value={gbp(it.taxable_income)} />
<Kv label="Liability" value={gbp(it.liability)} bold />
<Kv label="Withheld (PAYE)" value={gbp(it.withheld)} />
<OwedRow owed={it.owed} />
<BandTable bands={it.band_breakdown} />
</div>
{/* NI */}
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">National Insurance</p>
<Kv label="Liability" value={gbp(ni.liability)} bold />
<Kv label="Withheld (PAYE)" value={gbp(ni.withheld)} />
<OwedRow owed={ni.owed} />
<BandTable bands={ni.band_breakdown} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,234 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Loader2, SlidersHorizontal } from "lucide-react";
import { TAX_QUERY_KEYS, getConfiguredYears, getTaxReport, type TaxReport } from "@/api/tax";
import TaxYearSelector, { currentTaxYear, taxYearDisplay } from "./TaxYearSelector";
import TaxProfileCard from "./TaxProfileCard";
import PayslipTable from "./PayslipTable";
import TaxNISummaryCard from "./TaxNISummaryCard";
import CGTSection from "./CGTSection";
import DividendSection from "./DividendSection";
import OverallLiabilityCard from "./OverallLiabilityCard";
import RateConfigModal from "./RateConfigModal";
// ---------------------------------------------------------------------------
// Loading skeleton
// ---------------------------------------------------------------------------
function SkeletonBlock({ h = "h-6", w = "w-full" }: { h?: string; w?: string }) {
return <div className={`${h} ${w} rounded bg-secondary/60 animate-pulse`} />;
}
function ReportSkeleton() {
return (
<div className="space-y-6">
{/* at-a-glance */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-36" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<SkeletonBlock h="h-3" w="w-20" />
<SkeletonBlock h="h-5" w="w-24" />
</div>
))}
</div>
</div>
{/* profile + payslips */}
<div className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-28" />
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => <SkeletonBlock key={i} h="h-8" />)}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-20" />
<SkeletonBlock h="h-32" />
</div>
{/* income tax + NI */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-52" />
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">{Array.from({ length: 5 }).map((_, i) => <SkeletonBlock key={i} h="h-4" />)}</div>
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <SkeletonBlock key={i} h="h-4" />)}</div>
</div>
</div>
{/* CGT + dividends */}
{[48, 36].map(h => (
<div key={h} className="rounded-lg border border-border bg-card p-6 space-y-3">
<SkeletonBlock h="h-5" w="w-40" />
<SkeletonBlock h={`h-${h}`} />
</div>
))}
{/* overall */}
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<SkeletonBlock h="h-5" w="w-64" />
<div className="grid grid-cols-2 gap-4">
<SkeletonBlock h="h-20" />
<SkeletonBlock h="h-20" />
<SkeletonBlock h="h-24" w="col-span-2 w-full" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Quick-summary card — shows top-level numbers from report while full UI is built
// ---------------------------------------------------------------------------
function fmt(v: string) {
return new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }).format(Number(v));
}
function SummaryOverview({ report }: { report: TaxReport }) {
const items = [
{ label: "Gross income", value: fmt(report.income.gross_income) },
{ label: "Income tax", value: fmt(report.income_tax.liability) },
{ label: "National Insurance", value: fmt(report.ni.liability) },
{ label: "CGT", value: fmt(report.cgt.liability) },
{ label: "Dividend tax", value: fmt(report.dividends.liability) },
{ label: "Total liability", value: fmt(report.summary.total_liability), bold: true },
{ label: "Already withheld", value: fmt(report.summary.total_withheld) },
{
label: report.summary.overpaid ? "Overpaid" : "Still owed",
value: fmt(report.summary.net_owed),
highlight: true,
overpaid: report.summary.overpaid,
},
];
return (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-base font-semibold mb-4">
{taxYearDisplay(report.tax_year)} at a glance
</h2>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
{items.map(({ label, value, bold, highlight, overpaid }) => (
<div key={label}>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd
className={[
"text-sm font-medium mt-0.5",
bold ? "text-foreground font-semibold" : "",
highlight && overpaid ? "text-green-500" : "",
highlight && !overpaid ? "text-yellow-500" : "",
]
.filter(Boolean)
.join(" ")}
>
{value}
</dd>
</div>
))}
</dl>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function TaxPage() {
const { data: years = [], isLoading: yearsLoading } = useQuery({
queryKey: TAX_QUERY_KEYS.configuredYears,
queryFn: getConfiguredYears,
});
const [taxYear, setTaxYear] = useState<number | null>(null);
const [showRates, setShowRates] = useState(false);
// Once years load, pick the best default: current tax year if available,
// otherwise the highest configured year.
useEffect(() => {
if (years.length === 0) return;
const preferred = currentTaxYear();
setTaxYear(years.includes(preferred) ? preferred : Math.max(...years));
}, [years]);
const {
data: report,
isLoading: reportLoading,
isError,
error,
} = useQuery({
queryKey: TAX_QUERY_KEYS.report(taxYear ?? 0),
queryFn: () => getTaxReport(taxYear!),
enabled: taxYear !== null,
});
if (yearsLoading || taxYear === null) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading tax data
</div>
);
}
if (years.length === 0) {
return (
<div className="max-w-5xl mx-auto p-6">
<div className="rounded-lg border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">No tax rate configuration found.</p>
<p className="text-sm text-muted-foreground mt-1">
Run the database migration to seed default rates.
</p>
</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tax</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setShowRates(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
title="Edit tax rates"
>
<SlidersHorizontal className="w-4 h-4" />
Edit Rates
</button>
<TaxYearSelector years={years} value={taxYear} onChange={setTaxYear} />
</div>
</div>
{/* Disclaimer */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>Estimates only not financial or tax advice. Always verify against HMRC.</span>
</div>
{/* Report area */}
{reportLoading && <ReportSkeleton />}
{isError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{(error as Error)?.message ?? "Failed to load tax report."}
</div>
)}
{report && (
<>
<SummaryOverview report={report} />
<TaxProfileCard taxYear={taxYear} />
<PayslipTable taxYear={taxYear} />
<TaxNISummaryCard report={report} />
<CGTSection taxYear={taxYear} report={report} />
<DividendSection report={report} />
<OverallLiabilityCard report={report} />
</>
)}
{showRates && taxYear && (
<RateConfigModal taxYear={taxYear} onClose={() => setShowRates(false)} />
)}
</div>
);
}

View file

@ -0,0 +1,172 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Check, X, Loader2 } from "lucide-react";
import {
TAX_QUERY_KEYS,
getTaxProfile,
upsertTaxProfile,
type TaxProfileCreate,
} from "@/api/tax";
interface Props {
taxYear: number;
}
export default function TaxProfileCard({ taxYear }: Props) {
const qc = useQueryClient();
const { data: profile, isLoading, isError, error } = useQuery({
queryKey: TAX_QUERY_KEYS.profile(taxYear),
queryFn: () => getTaxProfile(taxYear),
retry: (_, err: unknown) => {
const status = (err as { response?: { status?: number } })?.response?.status;
return status !== 404;
},
});
const upsertMut = useMutation({
mutationFn: (data: TaxProfileCreate) => upsertTaxProfile(taxYear, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.profile(taxYear) });
qc.invalidateQueries({ queryKey: TAX_QUERY_KEYS.report(taxYear) });
setEditing(false);
},
});
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ tax_code: "", employer_name: "", is_cumulative: true });
function openEdit() {
setForm({
tax_code: profile?.tax_code ?? "1257L",
employer_name: profile?.employer_name ?? "",
is_cumulative: profile?.is_cumulative ?? true,
});
setEditing(true);
}
function handleSave() {
upsertMut.mutate({
tax_code: form.tax_code.trim() || "1257L",
employer_name: form.employer_name.trim() || null,
is_cumulative: form.is_cumulative,
});
}
const inp = "rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
const is404 = (error as { response?: { status?: number } })?.response?.status === 404;
return (
<div className="rounded-lg border border-border bg-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold">Tax Profile</h2>
{!editing && (
<button
onClick={openEdit}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
{profile || is404 ? "Edit" : "Set up"}
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Loading
</div>
)}
{!isLoading && !editing && (
<>
{isError && !is404 && (
<p className="text-sm text-destructive">Failed to load profile.</p>
)}
{(is404 || !profile) && (
<p className="text-sm text-muted-foreground">
No profile for this tax year.{" "}
<button onClick={openEdit} className="text-primary underline underline-offset-2">
Set one up
</button>{" "}
to improve estimate accuracy.
</p>
)}
{profile && (
<dl className="grid grid-cols-3 gap-6">
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Tax Code</dt>
<dd className="text-sm font-medium font-mono">{profile.tax_code}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Employer</dt>
<dd className="text-sm font-medium">{profile.employer_name ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Mode</dt>
<dd className="text-sm font-medium">{profile.is_cumulative ? "Cumulative" : "Week 1 / Month 1"}</dd>
</div>
</dl>
)}
</>
)}
{editing && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground block mb-1">Tax Code</label>
<input
value={form.tax_code}
onChange={e => setForm(f => ({ ...f, tax_code: e.target.value }))}
className={`${inp} w-full font-mono`}
placeholder="1257L"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Employer Name</label>
<input
value={form.employer_name}
onChange={e => setForm(f => ({ ...f, employer_name: e.target.value }))}
className={`${inp} w-full`}
placeholder="Optional"
/>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={form.is_cumulative}
onChange={e => setForm(f => ({ ...f, is_cumulative: e.target.checked }))}
className="rounded"
/>
Cumulative (normal PAYE; uncheck for Week 1/Month 1)
</label>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSave}
disabled={upsertMut.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{upsertMut.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Save
</button>
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-md hover:bg-secondary transition-colors"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
{upsertMut.isError && (
<p className="text-sm text-destructive">{(upsertMut.error as Error)?.message ?? "Failed to save"}</p>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
interface Props {
years: number[];
value: number;
onChange: (year: number) => void;
}
export function taxYearDisplay(year: number) {
return `${year - 1}/${String(year).slice(2)}`;
}
export function currentTaxYear(): number {
const now = new Date();
const y = now.getFullYear();
return now >= new Date(y, 3, 6) ? y + 1 : y;
}
export default function TaxYearSelector({ years, value, onChange }: Props) {
return (
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{years.map((y) => (
<option key={y} value={y}>
{taxYearDisplay(y)}
</option>
))}
</select>
);
}

View file

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Sparkles, CheckCircle, Repeat,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { formatCurrency } from "@/utils/currency";
@ -59,6 +59,7 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
const [parseResult, setParseResult] = useState<{ attId: string; data: ParsedReceipt } | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false);
const [isRecurring, setIsRecurring] = useState(transaction.is_recurring);
const fileInputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle;
@ -110,6 +111,18 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
},
});
const recurringMutation = useMutation({
mutationFn: (value: boolean) => updateTransaction(transaction.id, {
is_recurring: value,
recurring_rule: { manually_set: true },
}),
onSuccess: (_data, value) => {
setIsRecurring(value);
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["subscriptions"] });
},
});
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
setUploadError(null);
@ -196,6 +209,32 @@ export default function TransactionDetailDrawer({ transaction, accountName, cate
)}
</div>
{/* Recurring toggle */}
<div className="flex items-center justify-between py-3 px-4 bg-secondary/40 rounded-xl">
<div className="flex items-center gap-2">
<Repeat className="w-4 h-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Recurring payment</p>
<p className="text-xs text-muted-foreground">
{isRecurring ? "Appears in Subscriptions" : "Not marked as recurring"}
</p>
</div>
</div>
<button
onClick={() => recurringMutation.mutate(!isRecurring)}
disabled={recurringMutation.isPending}
className={cn(
"relative w-11 h-6 rounded-full transition-colors focus:outline-none disabled:opacity-50",
isRecurring ? "bg-primary" : "bg-muted"
)}
>
<span className={cn(
"absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform",
isRecurring ? "translate-x-5" : "translate-x-0"
)} />
</button>
</div>
{/* Attachments */}
<div>
<div className="flex items-center gap-2 mb-3">

View file

@ -10,7 +10,7 @@ import { cn } from "@/utils/cn";
import { format, startOfMonth, subMonths, startOfYear } from "date-fns";
import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip, RefreshCw, ScanLine, Loader2, Repeat,
} from "lucide-react";
import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer";
@ -326,6 +326,11 @@ export default function TransactionList() {
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate font-medium">{txn.description}</p>
{txn.is_recurring && (
<span title="Recurring payment">
<Repeat className="w-3 h-3 text-primary/60 shrink-0" />
</span>
)}
{txn.attachment_refs?.length > 0 && (
<Paperclip className="w-3 h-3 text-muted-foreground shrink-0" />
)}

View file

@ -36,7 +36,7 @@ const config: Config = {
foreground: "hsl(var(--card-foreground))",
},
success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" },
warning: { DEFAULT: "#f59e0b" },
warning: { DEFAULT: "hsl(var(--warning, 38 92% 58%))" },
},
borderRadius: {
lg: "var(--radius)",