feat: implement dashboard with KPI cards, category pie chart, and recent transactions
Wire dashboard to real DB data with period selector (month/3m/6m/12m/all), expense breakdown donut chart by category, and last 10 transactions list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2e7beea673
commit
84eca47afd
9 changed files with 461 additions and 10 deletions
64
src/components/dashboard/CategoryPieChart.tsx
Normal file
64
src/components/dashboard/CategoryPieChart.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
|
||||||
|
interface CategoryPieChartProps {
|
||||||
|
data: CategoryBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryPieChart({ data }: CategoryPieChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = data.reduce((sum, d) => sum + d.total, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="total"
|
||||||
|
nameKey="category_name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<Cell key={index} fill={item.category_color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(Number(value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
|
||||||
|
style={{ backgroundColor: item.category_color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
{item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/dashboard/PeriodSelector.tsx
Normal file
31
src/components/dashboard/PeriodSelector.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DashboardPeriod } from "../../shared/types";
|
||||||
|
|
||||||
|
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"];
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
value: DashboardPeriod;
|
||||||
|
onChange: (period: DashboardPeriod) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onChange(p)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
p === value
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`dashboard.period.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/dashboard/RecentTransactionsList.tsx
Normal file
51
src/components/dashboard/RecentTransactionsList.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { RecentTransaction } from "../../shared/types";
|
||||||
|
|
||||||
|
interface RecentTransactionsListProps {
|
||||||
|
transactions: RecentTransaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentTransactionsList({ transactions }: RecentTransactionsListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.recentTransactions")}</h2>
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.recentTransactions")}</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<div key={tx.id} className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm truncate">{tx.description}</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{tx.date}
|
||||||
|
{tx.category_name ? ` · ${tx.category_name}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium flex-shrink-0 ${
|
||||||
|
tx.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{fmt.format(tx.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/hooks/useDashboard.ts
Normal file
134
src/hooks/useDashboard.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
DashboardPeriod,
|
||||||
|
DashboardSummary,
|
||||||
|
CategoryBreakdownItem,
|
||||||
|
RecentTransaction,
|
||||||
|
} from "../shared/types";
|
||||||
|
import {
|
||||||
|
getDashboardSummary,
|
||||||
|
getExpensesByCategory,
|
||||||
|
getRecentTransactions,
|
||||||
|
} from "../services/dashboardService";
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
summary: DashboardSummary;
|
||||||
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
|
recentTransactions: RecentTransaction[];
|
||||||
|
period: DashboardPeriod;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardAction =
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
|
| {
|
||||||
|
type: "SET_DATA";
|
||||||
|
payload: {
|
||||||
|
summary: DashboardSummary;
|
||||||
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
|
recentTransactions: RecentTransaction[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "SET_PERIOD"; payload: DashboardPeriod };
|
||||||
|
|
||||||
|
const initialState: DashboardState = {
|
||||||
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
|
categoryBreakdown: [],
|
||||||
|
recentTransactions: [],
|
||||||
|
period: "month",
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: DashboardState, action: DashboardAction): DashboardState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
case "SET_DATA":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
summary: action.payload.summary,
|
||||||
|
categoryBreakdown: action.payload.categoryBreakdown,
|
||||||
|
recentTransactions: action.payload.recentTransactions,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
case "SET_PERIOD":
|
||||||
|
return { ...state, period: action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month":
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
case "3months":
|
||||||
|
from = new Date(year, month - 2, 1);
|
||||||
|
break;
|
||||||
|
case "6months":
|
||||||
|
from = new Date(year, month - 5, 1);
|
||||||
|
break;
|
||||||
|
case "12months":
|
||||||
|
from = new Date(year, month - 11, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (period: DashboardPeriod) => {
|
||||||
|
const fetchId = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
|
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
||||||
|
getDashboardSummary(dateFrom, dateTo),
|
||||||
|
getExpensesByCategory(dateFrom, dateTo),
|
||||||
|
getRecentTransactions(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, recentTransactions } });
|
||||||
|
} catch (e) {
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({
|
||||||
|
type: "SET_ERROR",
|
||||||
|
payload: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(state.period);
|
||||||
|
}, [state.period, fetchData]);
|
||||||
|
|
||||||
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setPeriod };
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,16 @@
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expenses": "Expenses",
|
"expenses": "Expenses",
|
||||||
"noData": "No data available. Start by importing your bank statements."
|
"noData": "No data available. Start by importing your bank statements.",
|
||||||
|
"expensesByCategory": "Expenses by Category",
|
||||||
|
"recentTransactions": "Recent Transactions",
|
||||||
|
"period": {
|
||||||
|
"month": "This month",
|
||||||
|
"3months": "3 months",
|
||||||
|
"6months": "6 months",
|
||||||
|
"12months": "12 months",
|
||||||
|
"all": "All"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Import Statements",
|
"title": "Import Statements",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,16 @@
|
||||||
"balance": "Solde",
|
"balance": "Solde",
|
||||||
"income": "Revenus",
|
"income": "Revenus",
|
||||||
"expenses": "Dépenses",
|
"expenses": "Dépenses",
|
||||||
"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",
|
||||||
|
"recentTransactions": "Transactions récentes",
|
||||||
|
"period": {
|
||||||
|
"month": "Ce mois",
|
||||||
|
"3months": "3 mois",
|
||||||
|
"6months": "6 mois",
|
||||||
|
"12months": "12 mois",
|
||||||
|
"all": "Tout"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importer des relevés",
|
"title": "Importer des relevés",
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,52 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||||
|
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" });
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { state, setPeriod } = useDashboard();
|
||||||
|
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
||||||
|
|
||||||
|
const balance = summary.totalAmount;
|
||||||
|
const balanceColor =
|
||||||
|
balance > 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: balance < 0
|
||||||
|
? "text-[var(--negative)]"
|
||||||
|
: "text-[var(--primary)]";
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
labelKey: "dashboard.balance",
|
labelKey: "dashboard.balance",
|
||||||
value: "0,00 €",
|
value: fmt.format(balance),
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
color: "text-[var(--primary)]",
|
color: balanceColor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "dashboard.income",
|
labelKey: "dashboard.income",
|
||||||
value: "0,00 €",
|
value: fmt.format(summary.incomeTotal),
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
color: "text-[var(--positive)]",
|
color: "text-[var(--positive)]",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "dashboard.expenses",
|
labelKey: "dashboard.expenses",
|
||||||
value: "0,00 €",
|
value: fmt.format(Math.abs(summary.expenseTotal)),
|
||||||
icon: TrendingDown,
|
icon: TrendingDown,
|
||||||
color: "text-[var(--negative)]",
|
color: "text-[var(--negative)]",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<h1 className="text-2xl font-bold mb-6">{t("dashboard.title")}</h1>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
</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-8">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
|
|
@ -46,8 +65,9 @@ export default function DashboardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<p>{t("dashboard.noData")}</p>
|
<CategoryPieChart data={categoryBreakdown} />
|
||||||
|
<RecentTransactionsList transactions={recentTransactions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
106
src/services/dashboardService.ts
Normal file
106
src/services/dashboardService.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import type {
|
||||||
|
DashboardSummary,
|
||||||
|
CategoryBreakdownItem,
|
||||||
|
RecentTransaction,
|
||||||
|
} from "../shared/types";
|
||||||
|
|
||||||
|
export async function getDashboardSummary(
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string
|
||||||
|
): Promise<DashboardSummary> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const whereClauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauses.push(`date >= $${paramIndex}`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauses.push(`date <= $${paramIndex}`);
|
||||||
|
params.push(dateTo);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL =
|
||||||
|
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const rows = await db.select<
|
||||||
|
Array<{
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
incomeTotal: number;
|
||||||
|
expenseTotal: number;
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) AS totalCount,
|
||||||
|
COALESCE(SUM(amount), 0) AS totalAmount,
|
||||||
|
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS incomeTotal,
|
||||||
|
COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0) AS expenseTotal
|
||||||
|
FROM transactions
|
||||||
|
${whereSQL}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ?? { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpensesByCategory(
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string
|
||||||
|
): Promise<CategoryBreakdownItem[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const whereClauses: string[] = ["t.amount < 0"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauses.push(`t.date <= $${paramIndex}`);
|
||||||
|
params.push(dateTo);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
||||||
|
return db.select<CategoryBreakdownItem[]>(
|
||||||
|
`SELECT
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
ABS(SUM(t.amount)) AS total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
${whereSQL}
|
||||||
|
GROUP BY t.category_id
|
||||||
|
ORDER BY total DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentTransactions(
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<RecentTransaction[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
return db.select<RecentTransaction[]>(
|
||||||
|
`SELECT
|
||||||
|
t.id, t.date, t.description, t.amount,
|
||||||
|
c.name AS category_name, c.color AS category_color
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
ORDER BY t.date DESC, t.id DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -202,6 +202,33 @@ export interface ImportReport {
|
||||||
errors: Array<{ rowIndex: number; message: string }>;
|
errors: Array<{ rowIndex: number; message: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dashboard Types ---
|
||||||
|
|
||||||
|
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all";
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
incomeTotal: number;
|
||||||
|
expenseTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryBreakdownItem {
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string;
|
||||||
|
category_color: string;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentTransaction {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
category_name: string | null;
|
||||||
|
category_color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportWizardStep =
|
export type ImportWizardStep =
|
||||||
| "source-list"
|
| "source-list"
|
||||||
| "source-config"
|
| "source-config"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue