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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,8 @@ import { useDashboard } from "../hooks/useDashboard";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart"; 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 TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types"; import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
@ -41,7 +42,7 @@ function computeDateRange(
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setPeriod, setCustomDates } = useDashboard(); 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 [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null); const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -108,7 +109,7 @@ export default function DashboardPage() {
/> />
</div> </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) => ( {cards.map((card) => (
<div <div
key={card.labelKey} key={card.labelKey}
@ -125,7 +126,7 @@ export default function DashboardPage() {
))} ))}
</div> </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 <CategoryPieChart
data={categoryBreakdown} data={categoryBreakdown}
hiddenCategories={hiddenCategories} hiddenCategories={hiddenCategories}
@ -133,7 +134,21 @@ export default function DashboardPage() {
onShowAll={showAll} onShowAll={showAll}
onViewDetails={viewDetails} 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> </div>
{detailModal && ( {detailModal && (