Compare commits

..

2 commits

Author SHA1 Message Date
le king fu
0bbbcc541b Revamp dashboard: YTD default, expenses chart, budget table
- Default period changed from month to year-to-date
- Remove recent transactions section
- Add expenses over time stacked bar chart (by category/month)
- Add budget vs actual table (current month)
- Reorganize layout: cards, pie + budget table, full-width chart

Closes #15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:15:04 -05:00
le king fu
861d78eca2 Improve visual hierarchy of subtotals and totals in all tables
Section subtotals: text-sm font-semibold with more padding.
Grand totals: text-sm font-bold with border-t-2 and extra padding.
Applied consistently across BudgetTable, BudgetVsActualTable,
MonthlyTrendsTable, CategoryOverTimeTable, CategoryTable,
and DynamicReportTable.

Closes #14

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:10:27 -05:00
10 changed files with 88 additions and 60 deletions

View file

@ -387,12 +387,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</tr>
{reorderRows(group, subtotalsOnTop).map((row) => renderRow(row))}
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
<td className="py-2 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-xs font-semibold">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
{t(typeTotalKeys[type])}
</td>
<td className="py-2 px-2 text-right text-xs font-semibold">{formatSigned(sectionAnnualTotal)}</td>
<td className="py-2.5 px-2 text-right text-sm font-semibold">{formatSigned(sectionAnnualTotal)}</td>
{sectionMonthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2 px-2 text-right text-xs font-semibold">
<td key={mIdx} className="py-2.5 px-2 text-right text-sm font-semibold">
{formatSigned(total)}
</td>
))}
@ -401,11 +401,11 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
);
})}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-semibold">
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-2.5 px-2 text-right text-xs">{formatSigned(annualTotal)}</td>
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
<td key={mIdx} className="py-3 px-2 text-right text-sm">
{formatSigned(total)}
</td>
))}

View file

@ -256,26 +256,26 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
</tr>
);
})}
<tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold">
<td className="px-3 py-2 text-xs">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold text-sm">
<td className="px-3 py-2.5">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(sectionTotals.monthVariation)}`}>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{cadFormatter(sectionTotals.monthVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(sectionTotals.monthVariation)}`}>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
{pctFormatter(sectionMonthPct)}
</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.ytdActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(sectionTotals.ytdVariation)}`}>
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{cadFormatter(sectionTotals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(sectionTotals.ytdVariation)}`}>
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
{pctFormatter(sectionYtdPct)}
</td>
</tr>
@ -283,26 +283,26 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
);
})}
{/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{cadFormatter(totals.monthVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
{pctFormatter(totalMonthPct)}
</td>
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.ytdActual)}
</td>
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{cadFormatter(totals.ytdVariation)}
</td>
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
{pctFormatter(totalYtdPct)}
</td>
</tr>

View file

@ -80,8 +80,8 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
</tr>
);
})}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const monthTotal = visibleCategories.reduce(
@ -89,12 +89,12 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
0,
);
return (
<td key={month} className="text-right px-3 py-2">
<td key={month} className="text-right px-3 py-3">
{cadFormatter(monthTotal)}
</td>
);
})}
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(
visibleCategories.reduce(
(sum, cat) => sum + data.data.reduce((s, d) => s + ((d as Record<string, unknown>)[cat] as number || 0), 0),

View file

@ -61,10 +61,10 @@ export default function CategoryTable({ data, hiddenCategories }: CategoryTableP
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-2 text-[var(--muted-foreground)]">100%</td>
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3">{cadFormatter(grandTotal)}</td>
<td className="text-right px-3 py-3 text-[var(--muted-foreground)]">100%</td>
</tr>
</tbody>
</table>

View file

@ -192,13 +192,13 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-2">
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-2 border-l border-[var(--border)]/50">
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))

View file

@ -61,11 +61,11 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
</td>
</tr>
))}
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
<td className="px-3 py-2">{t("common.total")}</td>
<td className="text-right px-3 py-2 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
<td className="text-right px-3 py-2 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
<td className={`text-right px-3 py-2 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td>
<td className="text-right px-3 py-3 text-[var(--positive)]">{cadFormatter(totals.income)}</td>
<td className="text-right px-3 py-3 text-[var(--negative)]">{cadFormatter(totals.expenses)}</td>
<td className={`text-right px-3 py-3 ${totals.income - totals.expenses >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}>
{cadFormatter(totals.income - totals.expenses)}
</td>
</tr>

View file

@ -3,18 +3,21 @@ import type {
DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem,
RecentTransaction,
CategoryOverTimeData,
BudgetVsActualRow,
} from "../shared/types";
import {
getDashboardSummary,
getExpensesByCategory,
getRecentTransactions,
} from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService";
interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
recentTransactions: RecentTransaction[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
@ -30,7 +33,8 @@ type DashboardAction =
payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
recentTransactions: RecentTransaction[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
};
}
| { type: "SET_PERIOD"; payload: DashboardPeriod }
@ -38,14 +42,15 @@ type DashboardAction =
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const yearStartStr = `${now.getFullYear()}-01-01`;
const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
recentTransactions: [],
period: "month",
customDateFrom: monthStartStr,
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetVsActual: [],
period: "year",
customDateFrom: yearStartStr,
customDateTo: todayStr,
isLoading: false,
error: null,
@ -62,7 +67,8 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown,
recentTransactions: action.payload.recentTransactions,
categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual,
isLoading: false,
};
case "SET_PERIOD":
@ -129,14 +135,17 @@ export function useDashboard() {
try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo),
getRecentTransactions(10),
getCategoryOverTime(dateFrom, dateTo),
getBudgetVsActualData(currentYear, currentMonth),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({

View file

@ -26,6 +26,8 @@
"noData": "No data available. Start by importing your bank statements.",
"expensesByCategory": "Expenses by Category",
"recentTransactions": "Recent Transactions",
"budgetVsActual": "Budget vs Actual",
"expensesOverTime": "Expenses Over Time",
"period": {
"month": "This month",
"3months": "3 months",

View file

@ -26,6 +26,8 @@
"noData": "Aucune donnée disponible. Commencez par importer vos relevés bancaires.",
"expensesByCategory": "Dépenses par catégorie",
"recentTransactions": "Transactions récentes",
"budgetVsActual": "Budget vs Réel",
"expensesOverTime": "Dépenses dans le temps",
"period": {
"month": "Ce mois",
"3months": "3 mois",

View file

@ -5,7 +5,8 @@ import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
@ -41,7 +42,7 @@ function computeDateRange(
export default function DashboardPage() {
const { t } = useTranslation();
const { state, setPeriod, setCustomDates } = useDashboard();
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -108,7 +109,7 @@ export default function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{cards.map((card) => (
<div
key={card.labelKey}
@ -125,7 +126,7 @@ export default function DashboardPage() {
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
@ -133,7 +134,21 @@ export default function DashboardPage() {
onShowAll={showAll}
onViewDetails={viewDetails}
/>
<RecentTransactionsList transactions={recentTransactions} />
<div>
<h2 className="text-lg font-semibold mb-3">{t("dashboard.budgetVsActual")}</h2>
<BudgetVsActualTable data={budgetVsActual} />
</div>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div>
{detailModal && (