mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 19:16:40 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
77
frontend/src/pages/AccountsPage.tsx
Normal file
77
frontend/src/pages/AccountsPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { AccountList } from "@/components/accounts/AccountList";
|
||||
import { AccountFormModal } from "@/components/accounts/AccountFormModal";
|
||||
import { useAccounts, useDeleteAccount, useUpdateAccount } from "@/hooks/useAccounts";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Account, AccountWithBalance } from "@/types";
|
||||
|
||||
export function AccountsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const { data: accounts, isLoading } = useAccounts(showArchived);
|
||||
const updateAccount = useUpdateAccount();
|
||||
const deleteAccount = useDeleteAccount();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingAccount(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(account: Account) {
|
||||
setEditingAccount(account);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleToggleArchived(account: AccountWithBalance) {
|
||||
updateAccount.mutate({ id: account.id, input: { is_archived: !account.is_archived } });
|
||||
}
|
||||
|
||||
function handleDelete(account: AccountWithBalance) {
|
||||
if (window.confirm(t("account.confirmDelete", { name: account.name }))) {
|
||||
deleteAccount.mutate(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.accounts")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<label className="mb-3 flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(event) => setShowArchived(event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-text-primary"
|
||||
/>
|
||||
{t("account.showArchived")}
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<AccountList
|
||||
items={accounts ?? []}
|
||||
onEdit={openEditModal}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccountFormModal open={modalOpen} onClose={() => setModalOpen(false)} account={editingAccount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/pages/AdvicePage.tsx
Normal file
84
frontend/src/pages/AdvicePage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Lightbulb, TrendingUp, TriangleAlert } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { useAdvice } from "@/hooks/useAdvice";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { AdviceItem } from "@/types";
|
||||
|
||||
const TONE_STYLES: Record<AdviceItem["tone"], string> = {
|
||||
positive: "border-success/30 bg-success/10 text-success",
|
||||
warning: "border-danger/30 bg-danger/10 text-danger",
|
||||
neutral: "border-border bg-surface-1 text-text-primary",
|
||||
};
|
||||
|
||||
const TONE_ICONS: Record<AdviceItem["tone"], typeof Lightbulb> = {
|
||||
positive: TrendingUp,
|
||||
warning: TriangleAlert,
|
||||
neutral: Lightbulb,
|
||||
};
|
||||
|
||||
// Each advice key needs its own params → translated message mapping since
|
||||
// some params (amounts) must be currency-formatted and category names
|
||||
// translated before interpolation — unlike AlertBanner's alerts, which only
|
||||
// ever interpolate raw numbers.
|
||||
function adviceMessage(item: AdviceItem, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
switch (item.key) {
|
||||
case "rising_category":
|
||||
return t("advice.risingCategory", {
|
||||
category: translateCategoryName(String(item.params.category)),
|
||||
percent: item.params.percent,
|
||||
current: formatCurrency(Number(item.params.current)),
|
||||
average: formatCurrency(Number(item.params.average)),
|
||||
});
|
||||
case "unbudgeted_top_category":
|
||||
return t("advice.unbudgetedTopCategory", {
|
||||
category: translateCategoryName(String(item.params.category)),
|
||||
amount: formatCurrency(Number(item.params.amount)),
|
||||
});
|
||||
case "savings_rate_trend": {
|
||||
const diff = Number(item.params.diff);
|
||||
return t("advice.savingsRateTrend", { rate: item.params.rate, diff: `${diff > 0 ? "+" : ""}${diff}` });
|
||||
}
|
||||
default:
|
||||
return item.key;
|
||||
}
|
||||
}
|
||||
|
||||
export function AdvicePage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useAdvice();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.advice")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("advice.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = TONE_ICONS[item.tone];
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-sm ${TONE_STYLES[item.tone]}`}
|
||||
>
|
||||
<Icon size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{adviceMessage(item, t)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/BudgetPage.tsx
Normal file
81
frontend/src/pages/BudgetPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { BudgetList } from "@/components/budget/BudgetList";
|
||||
import { BudgetFormModal } from "@/components/budget/BudgetFormModal";
|
||||
import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Budget, BudgetStatus } from "@/types";
|
||||
|
||||
export function BudgetPage() {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [year] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
|
||||
const { data: budgets } = useBudgets();
|
||||
const { data: status, isLoading } = useBudgetStatus(year, month);
|
||||
const deleteBudget = useDeleteBudget();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingBudget(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(item: BudgetStatus) {
|
||||
setEditingBudget({
|
||||
id: item.budget_id,
|
||||
category_id: item.category_id,
|
||||
category_name: item.category_name,
|
||||
category_color: item.category_color,
|
||||
category_icon: item.category_icon,
|
||||
monthly_limit: item.monthly_limit,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(item: BudgetStatus) {
|
||||
if (window.confirm(t("budget.confirmDelete", { name: translateCategoryName(item.category_name) }))) {
|
||||
deleteBudget.mutate(item.budget_id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner />
|
||||
|
||||
<MonthSelector month={month} onChange={setMonth} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.budget")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<BudgetList items={status?.items ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BudgetFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
budget={editingBudget}
|
||||
excludeCategoryIds={budgets?.map((budget) => budget.category_id) ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/CashFlowPage.tsx
Normal file
54
frontend/src/pages/CashFlowPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { CashFlowChart } from "@/components/cashflow/CashFlowChart";
|
||||
import { useCashFlow } from "@/hooks/useCashFlow";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
||||
const today = new Date();
|
||||
const end = isoDate(today);
|
||||
switch (preset) {
|
||||
case "all":
|
||||
return {};
|
||||
case "this_year":
|
||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
||||
case "12m": {
|
||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
case "5y": {
|
||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function CashFlowPage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<RangePreset>("12m");
|
||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||
{ value: "all", label: t("reports.rangeAll") },
|
||||
{ value: "5y", label: t("reports.range5y") },
|
||||
{ value: "12m", label: t("reports.range12m") },
|
||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||
];
|
||||
|
||||
const { startDate, endDate } = computeRange(range);
|
||||
const { data: cashFlow, isLoading } = useCashFlow(startDate, endDate);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex justify-end">
|
||||
<PillSelector options={RANGE_OPTIONS} value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
<CashFlowChart cashFlow={cashFlow} isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/DashboardPage.tsx
Normal file
76
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||
import { StatCard } from "@/components/dashboard/StatCard";
|
||||
import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard";
|
||||
import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { useDashboardSummary } from "@/hooks/useDashboard";
|
||||
import { formatCurrency, formatSignedCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
/** Share of income left over after spending (net / real_income). `null` when
|
||||
* there was no income to take a share of, rather than a misleading 0%. */
|
||||
function savingsRate(realIncome: number, net: number): number | null {
|
||||
return realIncome > 0 ? (net / realIncome) * 100 : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [year] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
|
||||
const { data, isLoading, isError } = useDashboardSummary(year, month);
|
||||
const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner excludeKeys={["risky_allocation_exceeded"]} />
|
||||
|
||||
<MonthSelector month={month} onChange={setMonth} />
|
||||
|
||||
{isError && (
|
||||
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||
{t("dashboard.errorLoading")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t("dashboard.statRealIncomeLabel")}
|
||||
value={isLoading ? "…" : formatCurrency(data?.real_income ?? 0)}
|
||||
caption={t("dashboard.statRealIncomeCaption")}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statSpentLabel")}
|
||||
value={isLoading ? "…" : formatCurrency(data?.spent ?? 0)}
|
||||
caption={t("dashboard.statSpentCaption")}
|
||||
tone="danger"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statNetLabel")}
|
||||
value={isLoading ? "…" : formatSignedCurrency(data?.net ?? 0)}
|
||||
caption={t("dashboard.statNetCaption")}
|
||||
tone={Number(data?.net ?? 0) >= 0 ? "success" : "danger"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statSavingsRateLabel")}
|
||||
value={isLoading ? "…" : rate === null ? "—" : formatPercent(rate)}
|
||||
caption={t("dashboard.statSavingsRateCaption")}
|
||||
tone={rate === null ? "default" : rate >= 0 ? "success" : "danger"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.4fr_1fr]">
|
||||
<SpendingByCategoryCard items={data?.spending_by_category ?? []} />
|
||||
<RecentTransactionsCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/GoalsPage.tsx
Normal file
75
frontend/src/pages/GoalsPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { GoalList } from "@/components/goals/GoalList";
|
||||
import { GoalFormModal } from "@/components/goals/GoalFormModal";
|
||||
import { GoalContributionModal } from "@/components/goals/GoalContributionModal";
|
||||
import { useDeleteGoal, useGoals } from "@/hooks/useGoals";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Goal } from "@/types";
|
||||
|
||||
export function GoalsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: goals, isLoading } = useGoals();
|
||||
const deleteGoal = useDeleteGoal();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null);
|
||||
const [contributionOpen, setContributionOpen] = useState(false);
|
||||
const [contributingGoal, setContributingGoal] = useState<Goal | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingGoal(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(goal: Goal) {
|
||||
setEditingGoal(goal);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openContributionModal(goal: Goal) {
|
||||
setContributingGoal(goal);
|
||||
setContributionOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(goal: Goal) {
|
||||
if (window.confirm(t("goal.confirmDelete", { name: goal.name }))) {
|
||||
deleteGoal.mutate(goal.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.goals")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<GoalList
|
||||
items={goals ?? []}
|
||||
onContribute={openContributionModal}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GoalFormModal open={formOpen} onClose={() => setFormOpen(false)} goal={editingGoal} />
|
||||
<GoalContributionModal
|
||||
open={contributionOpen}
|
||||
onClose={() => setContributionOpen(false)}
|
||||
goal={contributingGoal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/NetWorthPage.tsx
Normal file
75
frontend/src/pages/NetWorthPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { NetWorthChart } from "@/components/networth/NetWorthChart";
|
||||
import { AssetAllocationCard } from "@/components/networth/AssetAllocationCard";
|
||||
import { CapitalRoleSummaryCard } from "@/components/networth/CapitalRoleSummaryCard";
|
||||
import { RiskAllocationCard } from "@/components/networth/RiskAllocationCard";
|
||||
import { AssetsTable } from "@/components/networth/AssetsTable";
|
||||
import { AssetFormModal } from "@/components/networth/AssetFormModal";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { useNetWorthSummary } from "@/hooks/useNetWorth";
|
||||
import { useAssets, useDeleteAsset } from "@/hooks/useAssets";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Asset, NetWorthRange } from "@/types";
|
||||
|
||||
export function NetWorthPage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<NetWorthRange>("30d");
|
||||
const { data: summary, isLoading: isSummaryLoading } = useNetWorthSummary(range);
|
||||
const { data: assets, isLoading: isAssetsLoading } = useAssets();
|
||||
const deleteAsset = useDeleteAsset();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingAsset(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(asset: Asset) {
|
||||
setEditingAsset(asset);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(asset: Asset) {
|
||||
if (window.confirm(t("netWorth.confirmDeleteAsset", { name: asset.name }))) {
|
||||
deleteAsset.mutate(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner />
|
||||
|
||||
<NetWorthChart summary={summary} isLoading={isSummaryLoading} range={range} onRangeChange={setRange} />
|
||||
|
||||
<AssetAllocationCard breakdown={summary?.breakdown ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<CapitalRoleSummaryCard roles={summary?.capital_roles ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<RiskAllocationCard riskLevels={summary?.risk_levels ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("netWorth.myAssetsTitle")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAssetsLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<AssetsTable items={assets ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AssetFormModal open={modalOpen} onClose={() => setModalOpen(false)} asset={editingAsset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/RecurringPage.tsx
Normal file
68
frontend/src/pages/RecurringPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { RecurringList } from "@/components/recurring/RecurringList";
|
||||
import { RecurringFormModal } from "@/components/recurring/RecurringFormModal";
|
||||
import { useDeleteRecurring, usePostRecurring, useRecurring } from "@/hooks/useRecurring";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { RecurringTransaction } from "@/types";
|
||||
|
||||
export function RecurringPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: items, isLoading } = useRecurring();
|
||||
const deleteRecurring = useDeleteRecurring();
|
||||
const postRecurring = usePostRecurring();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<RecurringTransaction | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingItem(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(item: RecurringTransaction) {
|
||||
setEditingItem(item);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(item: RecurringTransaction) {
|
||||
if (window.confirm(t("recurring.confirmDelete", { name: item.description }))) {
|
||||
deleteRecurring.mutate(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePost(item: RecurringTransaction) {
|
||||
postRecurring.mutate(item.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.recurring")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<RecurringList
|
||||
items={items ?? []}
|
||||
onPost={handlePost}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDelete}
|
||||
isPosting={postRecurring.isPending}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RecurringFormModal open={modalOpen} onClose={() => setModalOpen(false)} recurring={editingItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/ReportsPage.tsx
Normal file
198
frontend/src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Label, Select } from "@/components/ui/Input";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { CategoryRankingCard } from "@/components/reports/CategoryRankingCard";
|
||||
import { CategorySpendingChart } from "@/components/reports/CategorySpendingChart";
|
||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCategoryRanking, useCategorySpendingReport } from "@/hooks/useReports";
|
||||
import { useDeleteTransaction, useTransactions } from "@/hooks/useTransactions";
|
||||
import type { TransactionSort } from "@/api/transactions";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Transaction } from "@/types";
|
||||
|
||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
||||
const today = new Date();
|
||||
const end = isoDate(today);
|
||||
switch (preset) {
|
||||
case "all":
|
||||
return {};
|
||||
case "this_year":
|
||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
||||
case "12m": {
|
||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
case "5y": {
|
||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||
{ value: "all", label: t("reports.rangeAll") },
|
||||
{ value: "5y", label: t("reports.range5y") },
|
||||
{ value: "12m", label: t("reports.range12m") },
|
||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||
];
|
||||
const { data: categories } = useCategories();
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const [range, setRange] = useState<RangePreset>("all");
|
||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryId === null && categories && categories.length > 0) {
|
||||
const firstExpense = categories.find((category) => category.kind === "expense");
|
||||
setCategoryId((firstExpense ?? categories[0]).id);
|
||||
}
|
||||
}, [categories, categoryId]);
|
||||
|
||||
const { startDate, endDate } = computeRange(range);
|
||||
const { data: ranking, isLoading: isRankingLoading } = useCategoryRanking("expense", startDate, endDate);
|
||||
const { data: report, isLoading: isReportLoading } = useCategorySpendingReport(categoryId, startDate, endDate);
|
||||
const { data: transactions, isLoading: isTransactionsLoading } = useTransactions({
|
||||
category_id: categoryId ?? undefined,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const deleteTransaction = useDeleteTransaction();
|
||||
|
||||
const expenseCategories = categories?.filter((category) => category.kind === "expense") ?? [];
|
||||
const incomeCategories = categories?.filter((category) => category.kind === "income") ?? [];
|
||||
const totalPages = transactions ? Math.max(1, Math.ceil(transactions.total / PAGE_SIZE)) : 1;
|
||||
|
||||
function handleEdit(transaction: Transaction) {
|
||||
setEditingTransaction(transaction);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(transaction: Transaction) {
|
||||
if (window.confirm(t("transactions.confirmDelete", { description: transaction.description }))) {
|
||||
deleteTransaction.mutate(transaction.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="max-w-xs flex-1">
|
||||
<Label htmlFor="report-category">{t("reports.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="report-category"
|
||||
value={categoryId ?? ""}
|
||||
onChange={(event) => {
|
||||
setCategoryId(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{expenseCategories.length > 0 && (
|
||||
<optgroup label={t("reports.expenseGroup")}>
|
||||
{expenseCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{incomeCategories.length > 0 && (
|
||||
<optgroup label={t("reports.incomeGroup")}>
|
||||
{incomeCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<PillSelector
|
||||
options={RANGE_OPTIONS}
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
setRange(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryRankingCard
|
||||
items={ranking?.items ?? []}
|
||||
isLoading={isRankingLoading}
|
||||
selectedCategoryId={categoryId}
|
||||
onSelectCategory={(id) => {
|
||||
setCategoryId(id);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategorySpendingChart report={report} isLoading={isReportLoading} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("reports.transactionsTitle")}</CardTitle>
|
||||
{transactions && (
|
||||
<span className="text-xs text-text-muted">{t("common.totalCount", { count: transactions.total })}</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value as TransactionSort);
|
||||
setPage(1);
|
||||
}}
|
||||
className="mb-3 sm:w-56"
|
||||
>
|
||||
<option value="date_desc">{t("transactions.sortDateDesc")}</option>
|
||||
<option value="amount_desc">{t("transactions.sortAmountDesc")}</option>
|
||||
<option value="amount_asc">{t("transactions.sortAmountAsc")}</option>
|
||||
</Select>
|
||||
{isTransactionsLoading ? (
|
||||
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<TransactionsTable items={transactions?.items ?? []} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-sm">
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<span className="text-text-muted">{t("common.pageOf", { page, total: totalPages })}</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TransactionFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
transaction={editingTransaction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/pages/RoiPage.tsx
Normal file
37
frontend/src/pages/RoiPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { RoiCalculatorCard } from "@/components/roi/RoiCalculatorCard";
|
||||
import { RoiProjectionCard } from "@/components/roi/RoiProjectionCard";
|
||||
|
||||
export function RoiPage() {
|
||||
const [investment, setInvestment] = useState("");
|
||||
const [monthlyIncome, setMonthlyIncome] = useState("");
|
||||
|
||||
const investmentAmount = Number(investment);
|
||||
const monthlyAmount = Number(monthlyIncome);
|
||||
const hasInputs = investment !== "" && monthlyIncome !== "" && investmentAmount > 0;
|
||||
|
||||
const annualIncome = monthlyAmount * 12;
|
||||
const annualRoiPercent = hasInputs ? (annualIncome / investmentAmount) * 100 : null;
|
||||
const paybackYears = annualRoiPercent !== null && annualIncome > 0 ? investmentAmount / annualIncome : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<RoiCalculatorCard
|
||||
investment={investment}
|
||||
onInvestmentChange={setInvestment}
|
||||
monthlyIncome={monthlyIncome}
|
||||
onMonthlyIncomeChange={setMonthlyIncome}
|
||||
annualRoiPercent={annualRoiPercent}
|
||||
paybackYears={paybackYears}
|
||||
/>
|
||||
|
||||
{annualRoiPercent !== null && (
|
||||
<RoiProjectionCard
|
||||
investmentAmount={investmentAmount}
|
||||
annualIncome={annualIncome}
|
||||
annualRoiPercent={annualRoiPercent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/pages/SettingsPage.tsx
Normal file
15
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AlertThresholdsCard } from "@/components/settings/AlertThresholdsCard";
|
||||
import { BackupCard } from "@/components/settings/BackupCard";
|
||||
import { CurrencyCard } from "@/components/settings/CurrencyCard";
|
||||
import { LanguageCard } from "@/components/settings/LanguageCard";
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<LanguageCard />
|
||||
<CurrencyCard />
|
||||
<AlertThresholdsCard />
|
||||
<BackupCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/pages/TransactionsPage.tsx
Normal file
150
frontend/src/pages/TransactionsPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Select } from "@/components/ui/Input";
|
||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
||||
import { useTransactions, useDeleteTransaction } from "@/hooks/useTransactions";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import type { TransactionSort } from "@/api/transactions";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Transaction, TransactionType } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function TransactionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [year] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const [type, setType] = useState<TransactionType | "">("");
|
||||
const [categoryId, setCategoryId] = useState<string>("");
|
||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||
|
||||
const { data: categories } = useCategories();
|
||||
const { data, isLoading, isError } = useTransactions({
|
||||
year,
|
||||
month,
|
||||
type: type || undefined,
|
||||
category_id: categoryId ? Number(categoryId) : undefined,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const deleteTransaction = useDeleteTransaction();
|
||||
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1;
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingTransaction(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(transaction: Transaction) {
|
||||
setEditingTransaction(transaction);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(transaction: Transaction) {
|
||||
if (window.confirm(t("transactions.confirmDelete", { description: transaction.description }))) {
|
||||
deleteTransaction.mutate(transaction.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<MonthSelector
|
||||
month={month}
|
||||
onChange={(value) => {
|
||||
setMonth(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={openCreateModal} className="w-full sm:w-auto">
|
||||
<Plus size={16} />
|
||||
{t("transactions.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(event) => {
|
||||
setType(event.target.value as TransactionType | "");
|
||||
setPage(1);
|
||||
}}
|
||||
className="sm:w-48"
|
||||
>
|
||||
<option value="">{t("transactions.allTypes")}</option>
|
||||
<option value="expense">{t("transactions.expense")}</option>
|
||||
<option value="income">{t("transactions.income")}</option>
|
||||
<option value="transfer">{t("transactions.transfer")}</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(event) => {
|
||||
setCategoryId(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sm:w-56"
|
||||
>
|
||||
<option value="">{t("transactions.allCategories")}</option>
|
||||
{categories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value as TransactionSort);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sm:w-56"
|
||||
>
|
||||
<option value="date_desc">{t("transactions.sortDateDesc")}</option>
|
||||
<option value="amount_desc">{t("transactions.sortAmountDesc")}</option>
|
||||
<option value="amount_asc">{t("transactions.sortAmountAsc")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.transactions")}</CardTitle>
|
||||
{data && <span className="text-xs text-text-muted">{t("common.totalCount", { count: data.total })}</span>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError && <p className="py-6 text-center text-sm text-danger">{t("transactions.failedToLoad")}</p>}
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<TransactionsTable items={data?.items ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-sm">
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<span className="text-text-muted">{t("common.pageOf", { page, total: totalPages })}</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TransactionFormModal open={modalOpen} onClose={() => setModalOpen(false)} transaction={editingTransaction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user