feat: add initial balance support to accounts, enhancing account management and balance calculations across components

This commit is contained in:
Julien Froidefond
2025-11-30 12:13:02 +01:00
parent c26ba9ddc6
commit 184a073bb1
13 changed files with 117 additions and 30 deletions

View File

@@ -13,6 +13,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Building2, Folder } from "lucide-react"; import { Building2, Folder } from "lucide-react";
import type { Account } from "@/lib/types"; import type { Account } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAccountBalance } from "@/lib/account-utils";
export default function AccountsPage() { export default function AccountsPage() {
const { data, isLoading, refresh } = useBankingData(); const { data, isLoading, refresh } = useBankingData();
@@ -26,6 +27,7 @@ export default function AccountsPage() {
type: "CHECKING" as Account["type"], type: "CHECKING" as Account["type"],
folderId: "folder-root", folderId: "folder-root",
externalUrl: "", externalUrl: "",
initialBalance: 0,
}); });
if (isLoading || !data) { if (isLoading || !data) {
@@ -46,6 +48,7 @@ export default function AccountsPage() {
type: account.type, type: account.type,
folderId: account.folderId || "folder-root", folderId: account.folderId || "folder-root",
externalUrl: account.externalUrl || "", externalUrl: account.externalUrl || "",
initialBalance: account.initialBalance || 0,
}); });
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -60,6 +63,7 @@ export default function AccountsPage() {
type: formData.type, type: formData.type,
folderId: formData.folderId, folderId: formData.folderId,
externalUrl: formData.externalUrl || null, externalUrl: formData.externalUrl || null,
initialBalance: formData.initialBalance,
}; };
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
refresh(); refresh();
@@ -126,7 +130,10 @@ export default function AccountsPage() {
return data.transactions.filter((t) => t.accountId === accountId).length; return data.transactions.filter((t) => t.accountId === accountId).length;
}; };
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0); const totalBalance = data.accounts.reduce(
(sum, a) => sum + getAccountBalance(a),
0,
);
// Grouper les comptes par folder // Grouper les comptes par folder
const accountsByFolder = data.accounts.reduce( const accountsByFolder = data.accounts.reduce(
@@ -194,6 +201,24 @@ export default function AccountsPage() {
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
({accountsByFolder["no-folder"].length}) ({accountsByFolder["no-folder"].length})
</span> </span>
<span
className={cn(
"text-sm font-semibold tabular-nums ml-auto",
accountsByFolder["no-folder"].reduce(
(sum, a) => sum + getAccountBalance(a),
0,
) >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(
accountsByFolder["no-folder"].reduce(
(sum, a) => sum + getAccountBalance(a),
0,
),
)}
</span>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accountsByFolder["no-folder"].map((account) => { {accountsByFolder["no-folder"].map((account) => {
@@ -225,7 +250,7 @@ export default function AccountsPage() {
if (folderAccounts.length === 0) return null; if (folderAccounts.length === 0) return null;
const folderBalance = folderAccounts.reduce( const folderBalance = folderAccounts.reduce(
(sum, a) => sum + a.balance, (sum, a) => sum + getAccountBalance(a),
0, 0,
); );

View File

@@ -10,6 +10,7 @@ import {
TopExpensesList, TopExpensesList,
} from "@/components/statistics"; } from "@/components/statistics";
import { useBankingData } from "@/lib/hooks"; import { useBankingData } from "@/lib/hooks";
import { getAccountBalance } from "@/lib/account-utils";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -231,7 +232,19 @@ export default function StatisticsPage() {
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
// Start with sum of initial balances for filtered accounts
let runningBalance = 0; let runningBalance = 0;
if (selectedAccounts.includes("all")) {
runningBalance = data.accounts.reduce(
(sum, acc) => sum + (acc.initialBalance || 0),
0,
);
} else {
runningBalance = data.accounts
.filter((acc) => selectedAccounts.includes(acc.id))
.reduce((sum, acc) => sum + (acc.initialBalance || 0), 0);
}
const aggregatedBalanceByDate = new Map<string, number>(); const aggregatedBalanceByDate = new Map<string, number>();
sortedFilteredTransactions.forEach((t) => { sortedFilteredTransactions.forEach((t) => {
runningBalance += t.amount; runningBalance += t.amount;
@@ -254,10 +267,10 @@ export default function StatisticsPage() {
accountBalances.set(account.id, new Map()); accountBalances.set(account.id, new Map());
}); });
// Calculate running balance per account // Calculate running balance per account (start with initialBalance)
const accountRunningBalances = new Map<string, number>(); const accountRunningBalances = new Map<string, number>();
data.accounts.forEach((account) => { data.accounts.forEach((account) => {
accountRunningBalances.set(account.id, 0); accountRunningBalances.set(account.id, account.initialBalance || 0);
}); });
sortedFilteredTransactions.forEach((t) => { sortedFilteredTransactions.forEach((t) => {
@@ -280,7 +293,7 @@ export default function StatisticsPage() {
const sortedDates = Array.from(allDates).sort(); const sortedDates = Array.from(allDates).sort();
const lastBalances = new Map<string, number>(); const lastBalances = new Map<string, number>();
data.accounts.forEach((account) => { data.accounts.forEach((account) => {
lastBalances.set(account.id, 0); lastBalances.set(account.id, account.initialBalance || 0);
}); });
const perAccountBalanceData = sortedDates.map((date) => { const perAccountBalanceData = sortedDates.map((date) => {

View File

@@ -14,6 +14,7 @@ import Link from "next/link";
import type { Account, Folder } from "@/lib/types"; import type { Account, Folder } from "@/lib/types";
import { accountTypeIcons, accountTypeLabels } from "./constants"; import { accountTypeIcons, accountTypeLabels } from "./constants";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { getAccountBalance } from "@/lib/account-utils";
interface AccountCardProps { interface AccountCardProps {
account: Account; account: Account;
@@ -37,10 +38,11 @@ export function AccountCard({
onSelect, onSelect,
}: AccountCardProps) { }: AccountCardProps) {
const Icon = accountTypeIcons[account.type]; const Icon = accountTypeIcons[account.type];
const realBalance = getAccountBalance(account);
return ( return (
<Card className={cn("relative", isSelected && "ring-2 ring-primary")}> <Card className={cn("relative", isSelected && "ring-2 ring-primary")}>
<CardHeader className="pb-1.5"> <CardHeader className="pb-0">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
{onSelect && ( {onSelect && (
@@ -89,14 +91,14 @@ export function AccountCard({
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-1.5"> <CardContent className="pt-1">
<div <div
className={cn( className={cn(
"text-xl font-bold mb-1.5", "text-xl font-bold mb-1.5",
account.balance >= 0 ? "text-emerald-600" : "text-red-600" realBalance >= 0 ? "text-emerald-600" : "text-red-600"
)} )}
> >
{formatCurrency(account.balance)} {formatCurrency(realBalance)}
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<Link <Link

View File

@@ -24,6 +24,7 @@ interface AccountFormData {
type: Account["type"]; type: Account["type"];
folderId: string; folderId: string;
externalUrl: string; externalUrl: string;
initialBalance: number;
} }
interface AccountEditDialogProps { interface AccountEditDialogProps {
@@ -99,6 +100,24 @@ export function AccountEditDialog({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label>Solde initial</Label>
<Input
type="number"
step="0.01"
value={formData.initialBalance}
onChange={(e) =>
onFormDataChange({
...formData,
initialBalance: parseFloat(e.target.value) || 0,
})
}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
Solde de départ pour équilibrer le compte
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Lien externe (portail banque)</Label> <Label>Lien externe (portail banque)</Label>
<Input <Input

View File

@@ -5,6 +5,7 @@ import { Progress } from "@/components/ui/progress";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Building2 } from "lucide-react"; import { Building2 } from "lucide-react";
import { getAccountBalance } from "@/lib/account-utils";
interface AccountsSummaryProps { interface AccountsSummaryProps {
data: BankingData; data: BankingData;
@@ -19,8 +20,8 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
}; };
const totalPositive = data.accounts const totalPositive = data.accounts
.filter((a) => a.balance > 0) .filter((a) => getAccountBalance(a) > 0)
.reduce((sum, a) => sum + a.balance, 0); .reduce((sum, a) => sum + getAccountBalance(a), 0);
if (data.accounts.length === 0) { if (data.accounts.length === 0) {
return ( return (
@@ -49,9 +50,10 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.accounts.map((account) => { {data.accounts.map((account) => {
const realBalance = getAccountBalance(account);
const percentage = const percentage =
totalPositive > 0 totalPositive > 0
? Math.max(0, (account.balance / totalPositive) * 100) ? Math.max(0, (realBalance / totalPositive) * 100)
: 0; : 0;
return ( return (
@@ -71,15 +73,15 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
<span <span
className={cn( className={cn(
"font-semibold tabular-nums", "font-semibold tabular-nums",
account.balance >= 0 realBalance >= 0
? "text-emerald-600" ? "text-emerald-600"
: "text-red-600", : "text-red-600",
)} )}
> >
{formatCurrency(account.balance)} {formatCurrency(realBalance)}
</span> </span>
</div> </div>
{account.balance > 0 && ( {realBalance > 0 && (
<Progress value={percentage} className="h-1.5" /> <Progress value={percentage} className="h-1.5" />
)} )}
</div> </div>

View File

@@ -3,13 +3,17 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react"; import { TrendingUp, TrendingDown, Wallet, CreditCard } from "lucide-react";
import type { BankingData } from "@/lib/types"; import type { BankingData } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
interface OverviewCardsProps { interface OverviewCardsProps {
data: BankingData; data: BankingData;
} }
export function OverviewCards({ data }: OverviewCardsProps) { export function OverviewCards({ data }: OverviewCardsProps) {
const totalBalance = data.accounts.reduce((sum, acc) => sum + acc.balance, 0); const totalBalance = data.accounts.reduce(
(sum, acc) => sum + getAccountBalance(acc),
0,
);
const thisMonth = new Date(); const thisMonth = new Date();
thisMonth.setDate(1); thisMonth.setDate(1);

View File

@@ -7,6 +7,7 @@ import { Building2, GripVertical, Pencil } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import type { Account } from "@/lib/types"; import type { Account } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
interface DraggableAccountItemProps { interface DraggableAccountItemProps {
account: Account; account: Account;
@@ -19,6 +20,7 @@ export function DraggableAccountItem({
onEditAccount, onEditAccount,
formatCurrency, formatCurrency,
}: DraggableAccountItemProps) { }: DraggableAccountItemProps) {
const realBalance = getAccountBalance(account);
const { const {
attributes, attributes,
listeners, listeners,
@@ -71,10 +73,10 @@ export function DraggableAccountItem({
<span <span
className={cn( className={cn(
"text-sm tabular-nums", "text-sm tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600" realBalance >= 0 ? "text-emerald-600" : "text-red-600"
)} )}
> >
{formatCurrency(account.balance)} {formatCurrency(realBalance)}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { DraggableFolderItem } from "./draggable-folder-item"; import { DraggableFolderItem } from "./draggable-folder-item";
import { DraggableAccountItem } from "./draggable-account-item"; import { DraggableAccountItem } from "./draggable-account-item";
import type { Folder as FolderType, Account } from "@/lib/types"; import type { Folder as FolderType, Account } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
interface FolderTreeItemProps { interface FolderTreeItemProps {
folder: FolderType; folder: FolderType;
@@ -35,7 +36,10 @@ export function FolderTreeItem({
(folder.id === "folder-root" && a.folderId === null) (folder.id === "folder-root" && a.folderId === null)
); );
const childFolders = allFolders.filter((f) => f.parentId === folder.id); const childFolders = allFolders.filter((f) => f.parentId === folder.id);
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0); const folderTotal = folderAccounts.reduce(
(sum, a) => sum + getAccountBalance(a),
0,
);
return ( return (
<div> <div>

9
lib/account-utils.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Account } from "./types";
/**
* Calcule le solde réel d'un compte en incluant le solde initial
*/
export function getAccountBalance(account: Account): number {
return (account.initialBalance || 0) + account.balance;
}

View File

@@ -22,6 +22,7 @@ export interface Account {
type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER"; type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER";
folderId: string | null; folderId: string | null;
balance: number; balance: number;
initialBalance: number;
currency: string; currency: string;
lastImport: string | null; lastImport: string | null;
externalUrl: string | null; externalUrl: string | null;

View File

@@ -11,18 +11,19 @@ datasource db {
} }
model Account { model Account {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
bankId String bankId String
accountNumber String accountNumber String
type String // CHECKING | SAVINGS | CREDIT_CARD | OTHER type String // CHECKING | SAVINGS | CREDIT_CARD | OTHER
folderId String? folderId String?
balance Float @default(0) balance Float @default(0)
currency String @default("EUR") initialBalance Float @default(0) // Solde de départ pour équilibrer
lastImport String? currency String @default("EUR")
externalUrl String? // Custom URL for external bank portal lastImport String?
createdAt DateTime @default(now()) externalUrl String? // Custom URL for external bank portal
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
transactions Transaction[] transactions Transaction[]

View File

@@ -11,6 +11,7 @@ export const accountService = {
type: data.type, type: data.type,
folderId: data.folderId, folderId: data.folderId,
balance: data.balance, balance: data.balance,
initialBalance: data.initialBalance ?? 0,
currency: data.currency, currency: data.currency,
lastImport: data.lastImport, lastImport: data.lastImport,
externalUrl: data.externalUrl, externalUrl: data.externalUrl,
@@ -25,6 +26,7 @@ export const accountService = {
type: created.type as Account["type"], type: created.type as Account["type"],
folderId: created.folderId, folderId: created.folderId,
balance: created.balance, balance: created.balance,
initialBalance: created.initialBalance,
currency: created.currency, currency: created.currency,
lastImport: created.lastImport, lastImport: created.lastImport,
externalUrl: created.externalUrl, externalUrl: created.externalUrl,
@@ -44,6 +46,7 @@ export const accountService = {
type: data.type, type: data.type,
folderId: data.folderId, folderId: data.folderId,
balance: data.balance, balance: data.balance,
initialBalance: data.initialBalance,
currency: data.currency, currency: data.currency,
lastImport: data.lastImport, lastImport: data.lastImport,
externalUrl: data.externalUrl, externalUrl: data.externalUrl,
@@ -58,6 +61,7 @@ export const accountService = {
type: updated.type as Account["type"], type: updated.type as Account["type"],
folderId: updated.folderId, folderId: updated.folderId,
balance: updated.balance, balance: updated.balance,
initialBalance: updated.initialBalance,
currency: updated.currency, currency: updated.currency,
lastImport: updated.lastImport, lastImport: updated.lastImport,
externalUrl: updated.externalUrl, externalUrl: updated.externalUrl,

View File

@@ -36,6 +36,7 @@ export const bankingService = {
type: a.type as Account["type"], type: a.type as Account["type"],
folderId: a.folderId, folderId: a.folderId,
balance: a.balance, balance: a.balance,
initialBalance: a.initialBalance,
currency: a.currency, currency: a.currency,
lastImport: a.lastImport, lastImport: a.lastImport,
externalUrl: a.externalUrl, externalUrl: a.externalUrl,