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:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
|
|
@ -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>
|
||||
|
|
|
|||
28
frontend/src/api/subscriptions.ts
Normal file
28
frontend/src/api/subscriptions.ts
Normal 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
309
frontend/src/api/tax.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"} />
|
||||
|
|
|
|||
269
frontend/src/pages/subscriptions/SubscriptionsPage.tsx
Normal file
269
frontend/src/pages/subscriptions/SubscriptionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
frontend/src/pages/tax/CGTSection.tsx
Normal file
245
frontend/src/pages/tax/CGTSection.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
frontend/src/pages/tax/DividendSection.tsx
Normal file
101
frontend/src/pages/tax/DividendSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
frontend/src/pages/tax/ManualDisposalFormModal.tsx
Normal file
112
frontend/src/pages/tax/ManualDisposalFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/pages/tax/OverallLiabilityCard.tsx
Normal file
77
frontend/src/pages/tax/OverallLiabilityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/tax/P60Modal.tsx
Normal file
115
frontend/src/pages/tax/P60Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/tax/PayslipFormModal.tsx
Normal file
163
frontend/src/pages/tax/PayslipFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
frontend/src/pages/tax/PayslipTable.tsx
Normal file
233
frontend/src/pages/tax/PayslipTable.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
440
frontend/src/pages/tax/RateConfigModal.tsx
Normal file
440
frontend/src/pages/tax/RateConfigModal.tsx
Normal 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 > 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/pages/tax/TaxNISummaryCard.tsx
Normal file
99
frontend/src/pages/tax/TaxNISummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
frontend/src/pages/tax/TaxPage.tsx
Normal file
234
frontend/src/pages/tax/TaxPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/tax/TaxProfileCard.tsx
Normal file
172
frontend/src/pages/tax/TaxProfileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/pages/tax/TaxYearSelector.tsx
Normal file
31
frontend/src/pages/tax/TaxYearSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue