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:
ZProger
2026-08-16 18:39:52 +03:00
commit fe9b079c12
199 changed files with 14410 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}