Compare commits
8 Commits
b2eac21bdf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4f6d31b8 | ||
|
|
804b0f0aad | ||
|
|
f295e86fc2 | ||
|
|
c57daa9cc8 | ||
|
|
01c1f25de2 | ||
|
|
9de7d1a467 | ||
|
|
407486a109 | ||
|
|
e0597b0dcb |
@@ -134,7 +134,11 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
// Convert accountsWithStats to regular accounts for compatibility
|
// Convert accountsWithStats to regular accounts for compatibility
|
||||||
const accounts = accountsWithStats.map(
|
const accounts = accountsWithStats.map(
|
||||||
({ transactionCount: _transactionCount, calculatedBalance: _calculatedBalance, ...account }) => account,
|
({
|
||||||
|
transactionCount: _transactionCount,
|
||||||
|
calculatedBalance: _calculatedBalance,
|
||||||
|
...account
|
||||||
|
}) => account,
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
@@ -167,7 +171,8 @@ export default function AccountsPage() {
|
|||||||
const balance = formData.totalBalance - formData.initialBalance;
|
const balance = formData.totalBalance - formData.initialBalance;
|
||||||
|
|
||||||
// Convertir "folder-root" en null
|
// Convertir "folder-root" en null
|
||||||
const folderId = formData.folderId === "folder-root" ? null : formData.folderId;
|
const folderId =
|
||||||
|
formData.folderId === "folder-root" ? null : formData.folderId;
|
||||||
|
|
||||||
const updatedAccount = {
|
const updatedAccount = {
|
||||||
...editingAccount,
|
...editingAccount,
|
||||||
@@ -384,7 +389,16 @@ export default function AccountsPage() {
|
|||||||
// Update cache directly
|
// Update cache directly
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["accounts-with-stats"],
|
["accounts-with-stats"],
|
||||||
(old: Array<Account & { transactionCount: number; calculatedBalance: number }> | undefined) => {
|
(
|
||||||
|
old:
|
||||||
|
| Array<
|
||||||
|
Account & {
|
||||||
|
transactionCount: number;
|
||||||
|
calculatedBalance: number;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
| undefined,
|
||||||
|
) => {
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return old.map((a) => (a.id === accountId ? updatedAccount : a));
|
return old.map((a) => (a.id === accountId ? updatedAccount : a));
|
||||||
},
|
},
|
||||||
@@ -573,7 +587,9 @@ export default function AccountsPage() {
|
|||||||
account={account}
|
account={account}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
calculatedBalance={accountWithStats?.calculatedBalance}
|
calculatedBalance={
|
||||||
|
accountWithStats?.calculatedBalance
|
||||||
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
@@ -694,7 +710,9 @@ export default function AccountsPage() {
|
|||||||
account={account}
|
account={account}
|
||||||
folder={accountFolder}
|
folder={accountFolder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
calculatedBalance={accountWithStats?.calculatedBalance}
|
calculatedBalance={
|
||||||
|
accountWithStats?.calculatedBalance
|
||||||
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
|
|||||||
@@ -126,4 +126,3 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,4 +66,3 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CategoriesPage() {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||||
new Set()
|
new Set(),
|
||||||
);
|
);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -55,7 +55,7 @@ export default function CategoriesPage() {
|
|||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
|
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
|
||||||
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
const [isRecategorizing, setIsRecategorizing] = useState(false);
|
||||||
@@ -63,7 +63,7 @@ export default function CategoriesPage() {
|
|||||||
// Persister l'état "tout déplier" dans le localStorage
|
// Persister l'état "tout déplier" dans le localStorage
|
||||||
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
|
const [expandAllByDefault, setExpandAllByDefault] = useLocalStorage(
|
||||||
"categories-expand-all-by-default",
|
"categories-expand-all-by-default",
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Organiser les catégories par parent
|
// Organiser les catégories par parent
|
||||||
@@ -77,7 +77,7 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parents = metadata.categories.filter(
|
const parents = metadata.categories.filter(
|
||||||
(c: Category) => c.parentId === null
|
(c: Category) => c.parentId === null,
|
||||||
);
|
);
|
||||||
const children: Record<string, Category[]> = {};
|
const children: Record<string, Category[]> = {};
|
||||||
const orphans: Category[] = [];
|
const orphans: Category[] = [];
|
||||||
@@ -86,7 +86,7 @@ export default function CategoriesPage() {
|
|||||||
.filter((c: Category) => c.parentId !== null)
|
.filter((c: Category) => c.parentId !== null)
|
||||||
.forEach((child: Category) => {
|
.forEach((child: Category) => {
|
||||||
const parentExists = parents.some(
|
const parentExists = parents.some(
|
||||||
(p: Category) => p.id === child.parentId
|
(p: Category) => p.id === child.parentId,
|
||||||
);
|
);
|
||||||
if (parentExists) {
|
if (parentExists) {
|
||||||
if (!children[child.parentId!]) {
|
if (!children[child.parentId!]) {
|
||||||
@@ -110,7 +110,7 @@ export default function CategoriesPage() {
|
|||||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
||||||
if (expandAllByDefault) {
|
if (expandAllByDefault) {
|
||||||
setExpandedParents(
|
setExpandedParents(
|
||||||
new Set(parentCategories.map((p: Category) => p.id))
|
new Set(parentCategories.map((p: Category) => p.id)),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setExpandedParents(new Set());
|
setExpandedParents(new Set());
|
||||||
@@ -150,7 +150,7 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
return { total, count };
|
return { total, count };
|
||||||
},
|
},
|
||||||
[categoryStats, childrenByParent]
|
[categoryStats, childrenByParent],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
|
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
|
||||||
@@ -264,7 +264,7 @@ export default function CategoriesPage() {
|
|||||||
try {
|
try {
|
||||||
// Fetch uncategorized transactions
|
// Fetch uncategorized transactions
|
||||||
const uncategorizedResponse = await fetch(
|
const uncategorizedResponse = await fetch(
|
||||||
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true"
|
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true",
|
||||||
);
|
);
|
||||||
if (!uncategorizedResponse.ok) {
|
if (!uncategorizedResponse.ok) {
|
||||||
throw new Error("Failed to fetch uncategorized transactions");
|
throw new Error("Failed to fetch uncategorized transactions");
|
||||||
@@ -277,11 +277,11 @@ export default function CategoriesPage() {
|
|||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
metadata.categories
|
metadata.categories,
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const category = metadata.categories.find(
|
const category = metadata.categories.find(
|
||||||
(c: Category) => c.id === categoryId
|
(c: Category) => c.id === categoryId,
|
||||||
);
|
);
|
||||||
if (category) {
|
if (category) {
|
||||||
results.push({ transaction, category });
|
results.push({ transaction, category });
|
||||||
@@ -315,9 +315,9 @@ export default function CategoriesPage() {
|
|||||||
return children.some(
|
return children.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase().includes(query) ||
|
c.name.toLowerCase().includes(query) ||
|
||||||
c.keywords.some((k) => k.toLowerCase().includes(query))
|
c.keywords.some((k) => k.toLowerCase().includes(query)),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -362,9 +362,9 @@ export default function CategoriesPage() {
|
|||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
c.keywords.some((k) =>
|
c.keywords.some((k) =>
|
||||||
k.toLowerCase().includes(searchQuery.toLowerCase())
|
k.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
) ||
|
) ||
|
||||||
parent.name.toLowerCase().includes(searchQuery.toLowerCase())
|
parent.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: allChildren;
|
: allChildren;
|
||||||
const stats = getCategoryStats(parent.id, true);
|
const stats = getCategoryStats(parent.id, true);
|
||||||
@@ -451,7 +451,7 @@ export default function CategoriesPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{new Date(result.transaction.date).toLocaleDateString(
|
{new Date(result.transaction.date).toLocaleDateString(
|
||||||
"fr-FR"
|
"fr-FR",
|
||||||
)}
|
)}
|
||||||
{" • "}
|
{" • "}
|
||||||
{new Intl.NumberFormat("fr-FR", {
|
{new Intl.NumberFormat("fr-FR", {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
|
|
||||||
/* Sidebar moderne avec glassmorphism très prononcé */
|
/* Sidebar moderne avec glassmorphism très prononcé */
|
||||||
--sidebar: oklch(1 0 0 / 0.5);
|
--sidebar: oklch(1 0 0 / 0.5);
|
||||||
|
--sidebar-opaque: oklch(1 0 0);
|
||||||
--sidebar-foreground: oklch(0.2 0.015 280);
|
--sidebar-foreground: oklch(0.2 0.015 280);
|
||||||
--sidebar-primary: oklch(0.6 0.25 270);
|
--sidebar-primary: oklch(0.6 0.25 270);
|
||||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||||
@@ -157,6 +158,7 @@
|
|||||||
|
|
||||||
/* Sidebar avec profondeur distincte */
|
/* Sidebar avec profondeur distincte */
|
||||||
--sidebar: oklch(0.1 0.01 260);
|
--sidebar: oklch(0.1 0.01 260);
|
||||||
|
--sidebar-opaque: oklch(0.1 0.01 260);
|
||||||
--sidebar-foreground: oklch(0.9 0.005 260);
|
--sidebar-foreground: oklch(0.9 0.005 260);
|
||||||
--sidebar-primary: oklch(0.72 0.19 220);
|
--sidebar-primary: oklch(0.72 0.19 220);
|
||||||
--sidebar-primary-foreground: oklch(0.12 0.008 260);
|
--sidebar-primary-foreground: oklch(0.12 0.008 260);
|
||||||
@@ -207,6 +209,7 @@
|
|||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-opaque: var(--sidebar-opaque);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
@@ -378,6 +381,29 @@
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Forcer l'opacité sur les wrappers Radix Portal */
|
||||||
|
[data-radix-portal],
|
||||||
|
[data-radix-portal] > *,
|
||||||
|
[data-radix-popper-content-wrapper],
|
||||||
|
[data-radix-popper-content-wrapper] > * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forcer l'opacité sur les tooltips Recharts */
|
||||||
|
.recharts-tooltip-wrapper,
|
||||||
|
.recharts-tooltip-wrapper > *,
|
||||||
|
.recharts-tooltip-wrapper > div,
|
||||||
|
.recharts-tooltip-wrapper > div > *,
|
||||||
|
.recharts-tooltip-cursor {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forcer l'opacité et le background opaque sur le contenu du tooltip */
|
||||||
|
.recharts-tooltip-wrapper > div {
|
||||||
|
background-color: var(--background) !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Glassmorphism effect très prononcé */
|
/* Glassmorphism effect très prononcé */
|
||||||
.glass {
|
.glass {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default function RootLayout({
|
|||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="light"
|
||||||
enableSystem
|
enableSystem={false}
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<BackgroundProvider />
|
<BackgroundProvider />
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ export default function RulesPage() {
|
|||||||
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
|
|
||||||
// Fetch uncategorized transactions only
|
// Fetch uncategorized transactions only
|
||||||
const {
|
const { data: transactionsData, isLoading: isLoadingTransactions } =
|
||||||
data: transactionsData,
|
useTransactions(
|
||||||
isLoading: isLoadingTransactions,
|
|
||||||
} = useTransactions(
|
|
||||||
{
|
{
|
||||||
limit: 10000, // Large limit to get all uncategorized
|
limit: 10000, // Large limit to get all uncategorized
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PasswordCard,
|
PasswordCard,
|
||||||
ReconcileDateRangeCard,
|
ReconcileDateRangeCard,
|
||||||
BackgroundCard,
|
BackgroundCard,
|
||||||
|
ThemeCard,
|
||||||
} from "@/components/settings";
|
} from "@/components/settings";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingData } from "@/lib/hooks";
|
||||||
import type { BankingData } from "@/lib/types";
|
import type { BankingData } from "@/lib/types";
|
||||||
@@ -127,6 +128,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<PasswordCard />
|
<PasswordCard />
|
||||||
|
|
||||||
|
<ThemeCard />
|
||||||
|
|
||||||
<BackgroundCard />
|
<BackgroundCard />
|
||||||
|
|
||||||
<ReconcileDateRangeCard />
|
<ReconcileDateRangeCard />
|
||||||
|
|||||||
@@ -106,7 +106,13 @@ export default function StatisticsPage() {
|
|||||||
setCustomStartDateISO(null);
|
setCustomStartDateISO(null);
|
||||||
setCustomEndDateISO(null);
|
setCustomEndDateISO(null);
|
||||||
}
|
}
|
||||||
}, [period, customStartDateISO, customEndDateISO, setCustomStartDateISO, setCustomEndDateISO]);
|
}, [
|
||||||
|
period,
|
||||||
|
customStartDateISO,
|
||||||
|
customEndDateISO,
|
||||||
|
setCustomStartDateISO,
|
||||||
|
setCustomEndDateISO,
|
||||||
|
]);
|
||||||
|
|
||||||
// Get start date based on period
|
// Get start date based on period
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
@@ -139,7 +145,7 @@ export default function StatisticsPage() {
|
|||||||
const internalTransferCategory = useMemo(() => {
|
const internalTransferCategory = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return data.categories.find(
|
return data.categories.find(
|
||||||
(c) => c.name.toLowerCase() === "virement interne",
|
(c) => c.name.toLowerCase() === "virement interne"
|
||||||
);
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -255,7 +261,7 @@ export default function StatisticsPage() {
|
|||||||
// Filter by accounts
|
// Filter by accounts
|
||||||
if (!selectedAccounts.includes("all")) {
|
if (!selectedAccounts.includes("all")) {
|
||||||
transactions = transactions.filter((t) =>
|
transactions = transactions.filter((t) =>
|
||||||
selectedAccounts.includes(t.accountId),
|
selectedAccounts.includes(t.accountId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +271,7 @@ export default function StatisticsPage() {
|
|||||||
transactions = transactions.filter((t) => !t.categoryId);
|
transactions = transactions.filter((t) => !t.categoryId);
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
|
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +279,7 @@ export default function StatisticsPage() {
|
|||||||
// Exclude "Virement interne" category if checkbox is checked
|
// Exclude "Virement interne" category if checkbox is checked
|
||||||
if (excludeInternalTransfers && internalTransferCategory) {
|
if (excludeInternalTransfers && internalTransferCategory) {
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
(t) => t.categoryId !== internalTransferCategory.id,
|
(t) => t.categoryId !== internalTransferCategory.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +352,7 @@ export default function StatisticsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const categoryChartDataByParent = Array.from(
|
const categoryChartDataByParent = Array.from(
|
||||||
categoryTotalsByParent.entries(),
|
categoryTotalsByParent.entries()
|
||||||
)
|
)
|
||||||
.map(([groupId, total]) => {
|
.map(([groupId, total]) => {
|
||||||
const category = data.categories.find((c) => c.id === groupId);
|
const category = data.categories.find((c) => c.id === groupId);
|
||||||
@@ -360,12 +366,37 @@ export default function StatisticsPage() {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
// Top expenses - deduplicate by ID and sort by amount (most negative first)
|
// Top expenses by top parent categories - deduplicate by ID
|
||||||
const uniqueTransactions = Array.from(
|
const uniqueTransactions = Array.from(
|
||||||
new Map(transactions.map((t) => [t.id, t])).values(),
|
new Map(transactions.map((t) => [t.id, t])).values()
|
||||||
);
|
);
|
||||||
const topExpenses = uniqueTransactions
|
const expenses = uniqueTransactions.filter((t) => t.amount < 0);
|
||||||
.filter((t) => t.amount < 0)
|
|
||||||
|
// Get top 5 parent categories by total expenses
|
||||||
|
const topParentCategories = Array.from(categoryTotalsByParent.entries())
|
||||||
|
.map(([groupId, total]) => ({
|
||||||
|
groupId: groupId === "uncategorized" ? null : groupId,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
// Get top 10 expenses per top parent category (from all its subcategories)
|
||||||
|
const topExpensesByCategory = topParentCategories.map(({ groupId }) => {
|
||||||
|
const categoryExpenses = expenses
|
||||||
|
.filter((t) => {
|
||||||
|
if (groupId === null) {
|
||||||
|
return !t.categoryId;
|
||||||
|
}
|
||||||
|
// Check if transaction belongs to this parent category or its subcategories
|
||||||
|
const category = data.categories.find((c) => c.id === t.categoryId);
|
||||||
|
if (!category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Use parent category ID if exists, otherwise use the category itself
|
||||||
|
const transactionGroupId = category.parentId || category.id;
|
||||||
|
return transactionGroupId === groupId;
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by amount (most negative first)
|
// Sort by amount (most negative first)
|
||||||
if (a.amount !== b.amount) {
|
if (a.amount !== b.amount) {
|
||||||
@@ -374,7 +405,13 @@ export default function StatisticsPage() {
|
|||||||
// If same amount, sort by date (most recent first) for stable sorting
|
// If same amount, sort by date (most recent first) for stable sorting
|
||||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: groupId,
|
||||||
|
expenses: categoryExpenses,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
@@ -388,7 +425,7 @@ export default function StatisticsPage() {
|
|||||||
|
|
||||||
// Balance evolution - Aggregated (using filtered transactions)
|
// Balance evolution - Aggregated (using filtered transactions)
|
||||||
const sortedFilteredTransactions = [...transactions].sort(
|
const sortedFilteredTransactions = [...transactions].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate starting balance: initialBalance + transactions before startDate
|
// Calculate starting balance: initialBalance + transactions before startDate
|
||||||
@@ -400,7 +437,7 @@ export default function StatisticsPage() {
|
|||||||
// Start with initial balances
|
// Start with initial balances
|
||||||
runningBalance = accountsToUse.reduce(
|
runningBalance = accountsToUse.reduce(
|
||||||
(sum, acc) => sum + (acc.initialBalance || 0),
|
(sum, acc) => sum + (acc.initialBalance || 0),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add all transactions before the start date for these accounts
|
// Add all transactions before the start date for these accounts
|
||||||
@@ -437,7 +474,7 @@ export default function StatisticsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aggregatedBalanceData = Array.from(
|
const aggregatedBalanceData = Array.from(
|
||||||
aggregatedBalanceByDate.entries(),
|
aggregatedBalanceByDate.entries()
|
||||||
).map(([date, balance]) => ({
|
).map(([date, balance]) => ({
|
||||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -653,13 +690,14 @@ export default function StatisticsPage() {
|
|||||||
monthlyChartData,
|
monthlyChartData,
|
||||||
categoryChartData,
|
categoryChartData,
|
||||||
categoryChartDataByParent,
|
categoryChartDataByParent,
|
||||||
topExpenses,
|
topExpensesByCategory,
|
||||||
totalIncome,
|
totalIncome,
|
||||||
totalExpenses,
|
totalExpenses,
|
||||||
avgMonthlyExpenses,
|
avgMonthlyExpenses,
|
||||||
aggregatedBalanceData,
|
aggregatedBalanceData,
|
||||||
perAccountBalanceData,
|
perAccountBalanceData,
|
||||||
transactionCount: transactions.length,
|
transactionCount: transactions.length,
|
||||||
|
transactions, // Toutes les transactions filtrées pour le graphique
|
||||||
savingsTrendData,
|
savingsTrendData,
|
||||||
categoryTrendData,
|
categoryTrendData,
|
||||||
categoryTrendDataByParent,
|
categoryTrendDataByParent,
|
||||||
@@ -894,7 +932,7 @@ export default function StatisticsPage() {
|
|||||||
onRemoveAccount={(id) => {
|
onRemoveAccount={(id) => {
|
||||||
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
||||||
setSelectedAccounts(
|
setSelectedAccounts(
|
||||||
newAccounts.length > 0 ? newAccounts : ["all"],
|
newAccounts.length > 0 ? newAccounts : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearAccounts={() => setSelectedAccounts(["all"])}
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
||||||
@@ -902,7 +940,7 @@ export default function StatisticsPage() {
|
|||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter((c) => c !== id);
|
const newCategories = selectedCategories.filter((c) => c !== id);
|
||||||
setSelectedCategories(
|
setSelectedCategories(
|
||||||
newCategories.length > 0 ? newCategories : ["all"],
|
newCategories.length > 0 ? newCategories : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
@@ -1000,7 +1038,11 @@ export default function StatisticsPage() {
|
|||||||
selected={customStartDate}
|
selected={customStartDate}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
setCustomStartDate(date);
|
setCustomStartDate(date);
|
||||||
if (date && customEndDate && date > customEndDate) {
|
if (
|
||||||
|
date &&
|
||||||
|
customEndDate &&
|
||||||
|
date > customEndDate
|
||||||
|
) {
|
||||||
setCustomEndDate(undefined);
|
setCustomEndDate(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1088,17 +1130,17 @@ export default function StatisticsPage() {
|
|||||||
onRemoveAccount={(id) => {
|
onRemoveAccount={(id) => {
|
||||||
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
||||||
setSelectedAccounts(
|
setSelectedAccounts(
|
||||||
newAccounts.length > 0 ? newAccounts : ["all"],
|
newAccounts.length > 0 ? newAccounts : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearAccounts={() => setSelectedAccounts(["all"])}
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onRemoveCategory={(id) => {
|
onRemoveCategory={(id) => {
|
||||||
const newCategories = selectedCategories.filter(
|
const newCategories = selectedCategories.filter(
|
||||||
(c) => c !== id,
|
(c) => c !== id
|
||||||
);
|
);
|
||||||
setSelectedCategories(
|
setSelectedCategories(
|
||||||
newCategories.length > 0 ? newCategories : ["all"],
|
newCategories.length > 0 ? newCategories : ["all"]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClearCategories={() => setSelectedCategories(["all"])}
|
onClearCategories={() => setSelectedCategories(["all"])}
|
||||||
@@ -1200,9 +1242,10 @@ export default function StatisticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 md:mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
<TopExpensesList
|
<TopExpensesList
|
||||||
expenses={stats.topExpenses}
|
expensesByCategory={stats.topExpensesByCategory}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
|
allTransactions={stats.transactions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1248,7 +1291,7 @@ function ActiveFilters({
|
|||||||
|
|
||||||
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
||||||
const selectedCats = categories.filter((c) =>
|
const selectedCats = categories.filter((c) =>
|
||||||
selectedCategories.includes(c.id),
|
selectedCategories.includes(c.id)
|
||||||
);
|
);
|
||||||
const isUncategorized = selectedCategories.includes("uncategorized");
|
const isUncategorized = selectedCategories.includes("uncategorized");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { PageLayout, PageHeader } from "@/components/layout";
|
import { PageLayout, PageHeader } from "@/components/layout";
|
||||||
import { RefreshCw, Receipt, Euro, ChevronDown, ChevronUp } from "lucide-react";
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Receipt,
|
||||||
|
Euro,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CheckCircle2,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
TransactionFilters,
|
TransactionFilters,
|
||||||
TransactionBulkActions,
|
TransactionBulkActions,
|
||||||
@@ -105,6 +113,7 @@ export default function TransactionsPage() {
|
|||||||
isLoading: isLoadingChart,
|
isLoading: isLoadingChart,
|
||||||
totalAmount: chartTotalAmount,
|
totalAmount: chartTotalAmount,
|
||||||
totalCount: chartTotalCount,
|
totalCount: chartTotalCount,
|
||||||
|
transactions: chartTransactions,
|
||||||
} = useTransactionsChartData({
|
} = useTransactionsChartData({
|
||||||
selectedAccounts,
|
selectedAccounts,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
@@ -147,7 +156,7 @@ export default function TransactionsPage() {
|
|||||||
handleBulkReconcile(reconciled, selectedTransactions);
|
handleBulkReconcile(reconciled, selectedTransactions);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
[handleBulkReconcile, selectedTransactions, clearSelection]
|
[handleBulkReconcile, selectedTransactions, clearSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBulkSetCategoryWithClear = useCallback(
|
const handleBulkSetCategoryWithClear = useCallback(
|
||||||
@@ -155,13 +164,13 @@ export default function TransactionsPage() {
|
|||||||
handleBulkSetCategory(categoryId, selectedTransactions);
|
handleBulkSetCategory(categoryId, selectedTransactions);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
},
|
},
|
||||||
[handleBulkSetCategory, selectedTransactions, clearSelection]
|
[handleBulkSetCategory, selectedTransactions, clearSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stabilize transactions reference to prevent unnecessary re-renders
|
// Stabilize transactions reference to prevent unnecessary re-renders
|
||||||
const filteredTransactions = useMemo(
|
const filteredTransactions = useMemo(
|
||||||
() => transactionsData?.transactions || [],
|
() => transactionsData?.transactions || [],
|
||||||
[transactionsData?.transactions]
|
[transactionsData?.transactions],
|
||||||
);
|
);
|
||||||
const totalTransactions = transactionsData?.total || 0;
|
const totalTransactions = transactionsData?.total || 0;
|
||||||
const hasMore = transactionsData?.hasMore || false;
|
const hasMore = transactionsData?.hasMore || false;
|
||||||
@@ -175,10 +184,27 @@ export default function TransactionsPage() {
|
|||||||
const totalAmount = chartTotalAmount ?? 0;
|
const totalAmount = chartTotalAmount ?? 0;
|
||||||
const displayTotalCount = chartTotalCount ?? totalTransactions;
|
const displayTotalCount = chartTotalCount ?? totalTransactions;
|
||||||
|
|
||||||
|
// Calculate percentages from chart transactions (all filtered transactions)
|
||||||
|
const reconciledPercent = useMemo(() => {
|
||||||
|
if (chartTransactions.length === 0) return "0.00";
|
||||||
|
const reconciledCount = chartTransactions.filter(
|
||||||
|
(t) => t.isReconciled,
|
||||||
|
).length;
|
||||||
|
return ((reconciledCount / chartTransactions.length) * 100).toFixed(2);
|
||||||
|
}, [chartTransactions]);
|
||||||
|
|
||||||
|
const categorizedPercent = useMemo(() => {
|
||||||
|
if (chartTransactions.length === 0) return "0.00";
|
||||||
|
const categorizedCount = chartTransactions.filter(
|
||||||
|
(t) => t.categoryId !== null,
|
||||||
|
).length;
|
||||||
|
return ((categorizedCount / chartTransactions.length) * 100).toFixed(2);
|
||||||
|
}, [chartTransactions]);
|
||||||
|
|
||||||
// Persist statistics collapsed state in localStorage
|
// Persist statistics collapsed state in localStorage
|
||||||
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
|
const [isStatsExpanded, setIsStatsExpanded] = useLocalStorage(
|
||||||
"transactions-stats-expanded",
|
"transactions-stats-expanded",
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Early return for loading state - prevents sidebar flash
|
// Early return for loading state - prevents sidebar flash
|
||||||
@@ -271,7 +297,7 @@ export default function TransactionsPage() {
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
{!isLoadingTransactions && (
|
{!isLoadingTransactions && (
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 mb-6">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
<Card className="card-hover">
|
<Card className="card-hover">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -283,7 +309,10 @@ export default function TransactionsPage() {
|
|||||||
{displayTotalCount}
|
{displayTotalCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Receipt className="w-8 h-8 text-muted-foreground" />
|
<Receipt
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: "var(--gray)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -304,7 +333,49 @@ export default function TransactionsPage() {
|
|||||||
{formatCurrency(totalAmount)}
|
{formatCurrency(totalAmount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Euro className="w-8 h-8 text-muted-foreground" />
|
<Euro
|
||||||
|
className={`w-8 h-8 ${
|
||||||
|
totalAmount >= 0
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Pointé
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 text-primary">
|
||||||
|
{reconciledPercent}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Catégorisé
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-2xl font-bold mt-1"
|
||||||
|
style={{ color: "var(--blue)" }}
|
||||||
|
>
|
||||||
|
{categorizedPercent}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
className="w-8 h-8"
|
||||||
|
style={{ color: "var(--blue)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export function AccountCard({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const Icon = accountTypeIcons[account.type];
|
const Icon = accountTypeIcons[account.type];
|
||||||
const realBalance = getAccountBalance(account);
|
const realBalance = getAccountBalance(account);
|
||||||
const hasBalanceDifference = calculatedBalance !== undefined &&
|
const hasBalanceDifference =
|
||||||
|
calculatedBalance !== undefined &&
|
||||||
Math.abs(account.balance - calculatedBalance) > 0.01;
|
Math.abs(account.balance - calculatedBalance) > 0.01;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -200,7 +201,8 @@ export function AccountCard({
|
|||||||
</span>
|
</span>
|
||||||
{hasBalanceDifference && (
|
{hasBalanceDifference && (
|
||||||
<span className="text-xs text-destructive font-semibold">
|
<span className="text-xs text-destructive font-semibold">
|
||||||
(diff: {formatCurrency(account.balance - calculatedBalance)})
|
(diff: {formatCurrency(account.balance - calculatedBalance)}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ export function AccountEditDialog({
|
|||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Solde de départ pour équilibrer le compte. Le balance sera calculé automatiquement (solde total - solde initial).
|
Solde de départ pour équilibrer le compte. Le balance sera calculé
|
||||||
|
automatiquement (solde total - solde initial).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ export function AccountMergeSelectDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMerge = async () => {
|
const handleMerge = async () => {
|
||||||
if (!sourceAccountId || !targetAccountId || sourceAccountId === targetAccountId) {
|
if (
|
||||||
|
!sourceAccountId ||
|
||||||
|
!targetAccountId ||
|
||||||
|
sourceAccountId === targetAccountId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +109,20 @@ export function AccountMergeSelectDialog({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Compte à conserver (destination)</Label>
|
<Label>Compte à conserver (destination)</Label>
|
||||||
<RadioGroup value={targetAccountId} onValueChange={setTargetAccountId}>
|
<RadioGroup
|
||||||
|
value={targetAccountId}
|
||||||
|
onValueChange={setTargetAccountId}
|
||||||
|
>
|
||||||
{selectedAccounts.map((account) => (
|
{selectedAccounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent"
|
className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent"
|
||||||
>
|
>
|
||||||
<RadioGroupItem value={account.id} id={account.id} className="mt-1" />
|
<RadioGroupItem
|
||||||
|
value={account.id}
|
||||||
|
id={account.id}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={account.id}
|
htmlFor={account.id}
|
||||||
className="flex-1 cursor-pointer space-y-1"
|
className="flex-1 cursor-pointer space-y-1"
|
||||||
@@ -120,7 +131,9 @@ export function AccountMergeSelectDialog({
|
|||||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
<div>Numéro: {account.accountNumber}</div>
|
<div>Numéro: {account.accountNumber}</div>
|
||||||
<div>Bank ID: {account.bankId}</div>
|
<div>Bank ID: {account.bankId}</div>
|
||||||
<div>Solde: {formatCurrency(getAccountBalance(account))}</div>
|
<div>
|
||||||
|
Solde: {formatCurrency(getAccountBalance(account))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,9 +146,9 @@ export function AccountMergeSelectDialog({
|
|||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<strong>Attention :</strong> Toutes les transactions du compte "
|
<strong>Attention :</strong> Toutes les transactions du compte "
|
||||||
{sourceAccount.name}" seront déplacées vers "{targetAccount.name}". Le
|
{sourceAccount.name}" seront déplacées vers "
|
||||||
compte "{sourceAccount.name}" sera supprimé après la fusion. Cette
|
{targetAccount.name}". Le compte "{sourceAccount.name}" sera
|
||||||
action est irréversible.
|
supprimé après la fusion. Cette action est irréversible.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -153,4 +166,3 @@ export function AccountMergeSelectDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,7 @@ export function AccountsSummary({ data }: AccountsSummaryProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-bold tabular-nums text-base",
|
"font-bold tabular-nums text-base",
|
||||||
realBalance >= 0
|
realBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
? "text-success"
|
|
||||||
: "text-destructive",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(realBalance)}
|
{formatCurrency(realBalance)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface OverviewCardsProps {
|
|||||||
export function OverviewCards({ data }: OverviewCardsProps) {
|
export function OverviewCards({ data }: OverviewCardsProps) {
|
||||||
const totalBalance = data.accounts.reduce(
|
const totalBalance = data.accounts.reduce(
|
||||||
(sum, acc) => sum + getAccountBalance(acc),
|
(sum, acc) => sum + getAccountBalance(acc),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const thisMonth = new Date();
|
const thisMonth = new Date();
|
||||||
@@ -27,7 +27,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
const thisMonthStr = thisMonth.toISOString().slice(0, 7);
|
const thisMonthStr = thisMonth.toISOString().slice(0, 7);
|
||||||
|
|
||||||
const monthTransactions = data.transactions.filter((t) =>
|
const monthTransactions = data.transactions.filter((t) =>
|
||||||
t.date.startsWith(thisMonthStr)
|
t.date.startsWith(thisMonthStr),
|
||||||
);
|
);
|
||||||
|
|
||||||
const income = monthTransactions
|
const income = monthTransactions
|
||||||
@@ -41,13 +41,13 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
const reconciled = data.transactions.filter((t) => t.isReconciled).length;
|
const reconciled = data.transactions.filter((t) => t.isReconciled).length;
|
||||||
const total = data.transactions.length;
|
const total = data.transactions.length;
|
||||||
const reconciledPercent =
|
const reconciledPercent =
|
||||||
total > 0 ? Math.round((reconciled / total) * 100) : 0;
|
total > 0 ? ((reconciled / total) * 100).toFixed(2) : "0.00";
|
||||||
|
|
||||||
const categorized = data.transactions.filter(
|
const categorized = data.transactions.filter(
|
||||||
(t) => t.categoryId !== null
|
(t) => t.categoryId !== null,
|
||||||
).length;
|
).length;
|
||||||
const categorizedPercent =
|
const categorizedPercent =
|
||||||
total > 0 ? Math.round((categorized / total) * 100) : 0;
|
total > 0 ? ((categorized / total) * 100).toFixed(2) : "0.00";
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -61,7 +61,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<Wallet className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary" strokeWidth={1} />
|
<Wallet
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-primary"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
@@ -72,9 +75,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words",
|
"text-2xl sm:text-3xl md:text-3xl lg:text-2xl xl:text-3xl font-black tracking-tight mb-3 leading-tight break-words",
|
||||||
totalBalance >= 0
|
totalBalance >= 0 ? "text-success" : "text-destructive",
|
||||||
? "text-success"
|
|
||||||
: "text-destructive"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
@@ -88,7 +89,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingUp className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success" strokeWidth={1} />
|
<TrendingUp
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-success"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
@@ -111,7 +115,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingDown className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive" strokeWidth={1} />
|
<TrendingDown
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-destructive"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
@@ -134,7 +141,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<CreditCard className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4" strokeWidth={1} />
|
<CreditCard
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-4"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
@@ -154,7 +164,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
|
|||||||
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
|
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<Tag className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-5" strokeWidth={1} />
|
<Tag
|
||||||
|
className="h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28 text-chart-5"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3 px-5 pt-5 sm:px-6 sm:pt-6 relative z-10">
|
||||||
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
<CardTitle className="text-[10px] sm:text-xs font-semibold text-muted-foreground/80 leading-tight uppercase tracking-wider flex-1 min-w-0">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
"font-black tabular-nums text-sm md:text-base shrink-0 md:hidden",
|
"font-black tabular-nums text-sm md:text-base shrink-0 md:hidden",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-destructive"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -131,7 +131,7 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
|
|||||||
"font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight",
|
"font-black tabular-nums text-base md:text-lg shrink-0 hidden md:block leading-tight",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-destructive"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function SidebarContent({
|
|||||||
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm",
|
"bg-gradient-to-r from-primary/15 via-primary/10 to-primary/5 border-2 border-primary/30 shadow-lg shadow-primary/10 backdrop-blur-sm",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto"
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
@@ -103,7 +103,7 @@ function SidebarContent({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold text-sm",
|
"font-semibold text-sm",
|
||||||
isActive && "text-primary font-bold"
|
isActive && "text-primary font-bold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -118,7 +118,7 @@ function SidebarContent({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-t border-border/30 pt-2",
|
"border-t border-border/30 pt-2",
|
||||||
collapsed ? "p-2" : "p-4"
|
collapsed ? "p-2" : "p-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link href="/settings" onClick={handleLinkClick} className="block mb-2">
|
<Link href="/settings" onClick={handleLinkClick} className="block mb-2">
|
||||||
@@ -126,7 +126,7 @@ function SidebarContent({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto"
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5 shrink-0" />
|
<Settings className="w-5 h-5 shrink-0" />
|
||||||
@@ -141,7 +141,7 @@ function SidebarContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start gap-3 h-12 rounded-2xl px-3 mb-2",
|
"w-full justify-start gap-3 h-12 rounded-2xl px-3 mb-2",
|
||||||
"text-destructive",
|
"text-destructive",
|
||||||
collapsed && "justify-center px-2 w-12 mx-auto"
|
collapsed && "justify-center px-2 w-12 mx-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5 shrink-0" />
|
<LogOut className="w-5 h-5 shrink-0" />
|
||||||
@@ -168,7 +168,8 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="left"
|
side="left"
|
||||||
className="w-64 p-0 bg-sidebar text-sidebar-foreground border-sidebar-border"
|
className="w-64 p-0 text-sidebar-foreground border-sidebar-border"
|
||||||
|
style={{ backgroundColor: 'var(--sidebar-opaque)' }}
|
||||||
>
|
>
|
||||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -187,13 +188,13 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex flex-col h-screen bg-sidebar text-sidebar-foreground border-r border-sidebar-border",
|
"hidden md:flex flex-col h-screen bg-sidebar text-sidebar-foreground border-r border-sidebar-border",
|
||||||
"backdrop-blur-xl",
|
"backdrop-blur-xl",
|
||||||
collapsed ? "w-16" : "w-64"
|
collapsed ? "w-16" : "w-64",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center border-b border-border/30",
|
"flex items-center border-b border-border/30",
|
||||||
collapsed ? "justify-center p-4" : "justify-between p-6"
|
collapsed ? "justify-center p-4" : "justify-between p-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function BackgroundProvider() {
|
|||||||
const applyBackground = () => {
|
const applyBackground = () => {
|
||||||
try {
|
try {
|
||||||
const pageBackground = document.querySelector(
|
const pageBackground = document.querySelector(
|
||||||
".page-background"
|
".page-background",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (!pageBackground) return;
|
if (!pageBackground) return;
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export function BackgroundProvider() {
|
|||||||
"bg-gradient-orange",
|
"bg-gradient-orange",
|
||||||
"bg-solid-light",
|
"bg-solid-light",
|
||||||
"bg-solid-dark",
|
"bg-solid-dark",
|
||||||
"bg-custom-image"
|
"bg-custom-image",
|
||||||
);
|
);
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -39,7 +39,7 @@ export function BackgroundProvider() {
|
|||||||
pageBackground.classList.add("bg-custom-image");
|
pageBackground.classList.add("bg-custom-image");
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
"--custom-background-image",
|
"--custom-background-image",
|
||||||
`url(${settings.customImageUrl})`
|
`url(${settings.customImageUrl})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
||||||
|
|||||||
@@ -40,43 +40,50 @@ const DEFAULT_BACKGROUNDS: Array<{
|
|||||||
{
|
{
|
||||||
value: "default",
|
value: "default",
|
||||||
label: "Neutre",
|
label: "Neutre",
|
||||||
preview: "linear-gradient(135deg, oklch(0.985 0 0) 0%, oklch(0.97 0.005 260) 50%, oklch(0.985 0 0) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.985 0 0) 0%, oklch(0.97 0.005 260) 50%, oklch(0.985 0 0) 100%)",
|
||||||
description: "Fond neutre et élégant",
|
description: "Fond neutre et élégant",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gradient-blue",
|
value: "gradient-blue",
|
||||||
label: "Océan",
|
label: "Océan",
|
||||||
preview: "linear-gradient(135deg, oklch(0.95 0.03 230) 0%, oklch(0.88 0.08 225) 50%, oklch(0.78 0.12 220) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.03 230) 0%, oklch(0.88 0.08 225) 50%, oklch(0.78 0.12 220) 100%)",
|
||||||
description: "Dégradé bleu apaisant",
|
description: "Dégradé bleu apaisant",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gradient-purple",
|
value: "gradient-purple",
|
||||||
label: "Améthyste",
|
label: "Améthyste",
|
||||||
preview: "linear-gradient(135deg, oklch(0.95 0.04 300) 0%, oklch(0.88 0.1 295) 50%, oklch(0.78 0.15 290) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.04 300) 0%, oklch(0.88 0.1 295) 50%, oklch(0.78 0.15 290) 100%)",
|
||||||
description: "Dégradé violet sophistiqué",
|
description: "Dégradé violet sophistiqué",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gradient-green",
|
value: "gradient-green",
|
||||||
label: "Forêt",
|
label: "Forêt",
|
||||||
preview: "linear-gradient(135deg, oklch(0.95 0.04 160) 0%, oklch(0.88 0.1 155) 50%, oklch(0.78 0.14 150) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.95 0.04 160) 0%, oklch(0.88 0.1 155) 50%, oklch(0.78 0.14 150) 100%)",
|
||||||
description: "Dégradé vert naturel",
|
description: "Dégradé vert naturel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gradient-orange",
|
value: "gradient-orange",
|
||||||
label: "Aurore",
|
label: "Aurore",
|
||||||
preview: "linear-gradient(135deg, oklch(0.97 0.03 80) 0%, oklch(0.92 0.08 60) 50%, oklch(0.85 0.14 45) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.97 0.03 80) 0%, oklch(0.92 0.08 60) 50%, oklch(0.85 0.14 45) 100%)",
|
||||||
description: "Dégradé orange chaleureux",
|
description: "Dégradé orange chaleureux",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "solid-light",
|
value: "solid-light",
|
||||||
label: "Lumineux",
|
label: "Lumineux",
|
||||||
preview: "linear-gradient(135deg, oklch(1 0 0) 0%, oklch(0.98 0.005 260) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(1 0 0) 0%, oklch(0.98 0.005 260) 100%)",
|
||||||
description: "Fond blanc épuré",
|
description: "Fond blanc épuré",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "solid-dark",
|
value: "solid-dark",
|
||||||
label: "Minuit",
|
label: "Minuit",
|
||||||
preview: "linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.08 0.015 250) 100%)",
|
preview:
|
||||||
|
"linear-gradient(135deg, oklch(0.18 0.02 260) 0%, oklch(0.08 0.015 250) 100%)",
|
||||||
description: "Fond sombre immersif",
|
description: "Fond sombre immersif",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -89,14 +96,14 @@ export function BackgroundCard() {
|
|||||||
|
|
||||||
const currentSettings = useMemo<BackgroundSettings>(
|
const currentSettings = useMemo<BackgroundSettings>(
|
||||||
() => backgroundSettings || { type: "default" },
|
() => backgroundSettings || { type: "default" },
|
||||||
[backgroundSettings]
|
[backgroundSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [customImageUrl, setCustomImageUrl] = useState(
|
const [customImageUrl, setCustomImageUrl] = useState(
|
||||||
currentSettings.customImageUrl || ""
|
currentSettings.customImageUrl || "",
|
||||||
);
|
);
|
||||||
const [showCustomInput, setShowCustomInput] = useState(
|
const [showCustomInput, setShowCustomInput] = useState(
|
||||||
currentSettings.type === "custom-image"
|
currentSettings.type === "custom-image",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Synchroniser customImageUrl avec les settings
|
// Synchroniser customImageUrl avec les settings
|
||||||
@@ -112,7 +119,7 @@ export function BackgroundCard() {
|
|||||||
const applyBackground = (settings: BackgroundSettings) => {
|
const applyBackground = (settings: BackgroundSettings) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const pageBackground = document.querySelector(
|
const pageBackground = document.querySelector(
|
||||||
".page-background"
|
".page-background",
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
|
|
||||||
if (!pageBackground) return;
|
if (!pageBackground) return;
|
||||||
@@ -126,14 +133,14 @@ export function BackgroundCard() {
|
|||||||
"bg-gradient-orange",
|
"bg-gradient-orange",
|
||||||
"bg-solid-light",
|
"bg-solid-light",
|
||||||
"bg-solid-dark",
|
"bg-solid-dark",
|
||||||
"bg-custom-image"
|
"bg-custom-image",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (settings.type === "custom-image" && settings.customImageUrl) {
|
if (settings.type === "custom-image" && settings.customImageUrl) {
|
||||||
pageBackground.classList.add("bg-custom-image");
|
pageBackground.classList.add("bg-custom-image");
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
"--custom-background-image",
|
"--custom-background-image",
|
||||||
`url(${settings.customImageUrl})`
|
`url(${settings.customImageUrl})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
pageBackground.classList.add(`bg-${settings.type || "default"}`);
|
||||||
@@ -142,7 +149,7 @@ export function BackgroundCard() {
|
|||||||
|
|
||||||
// Déclencher un événement personnalisé pour notifier les autres composants
|
// Déclencher un événement personnalisé pour notifier les autres composants
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("background-changed", { detail: settings })
|
new CustomEvent("background-changed", { detail: settings }),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,7 +229,7 @@ export function BackgroundCard() {
|
|||||||
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
currentSettings.type === bg.value
|
currentSettings.type === bg.value
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-border hover:border-primary/50"
|
: "border-border hover:border-primary/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
@@ -245,7 +252,7 @@ export function BackgroundCard() {
|
|||||||
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
currentSettings.type === "custom-image"
|
currentSettings.type === "custom-image"
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-border hover:border-primary/50"
|
: "border-border hover:border-primary/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function DangerZoneCard({
|
|||||||
const result = await onDeduplicate();
|
const result = await onDeduplicate();
|
||||||
if (result.deletedCount > 0) {
|
if (result.deletedCount > 0) {
|
||||||
alert(
|
alert(
|
||||||
`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`
|
`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
alert("Aucun doublon trouvé");
|
alert("Aucun doublon trouvé");
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export { BackupCard } from "./backup-card";
|
|||||||
export { PasswordCard } from "./password-card";
|
export { PasswordCard } from "./password-card";
|
||||||
export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
|
export { ReconcileDateRangeCard } from "./reconcile-date-range-card";
|
||||||
export { BackgroundCard } from "./background-card";
|
export { BackgroundCard } from "./background-card";
|
||||||
|
export { ThemeCard } from "./theme-card";
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function ReconcileDateRangeCard() {
|
|||||||
setIsReconciling(true);
|
setIsReconciling(true);
|
||||||
try {
|
try {
|
||||||
const endDateStr = format(endDate, "yyyy-MM-dd");
|
const endDateStr = format(endDate, "yyyy-MM-dd");
|
||||||
const body: { endDate: string; startDate?: string; reconciled: boolean } = {
|
const body: { endDate: string; startDate?: string; reconciled: boolean } =
|
||||||
|
{
|
||||||
endDate: endDateStr,
|
endDate: endDateStr,
|
||||||
reconciled: true,
|
reconciled: true,
|
||||||
};
|
};
|
||||||
@@ -134,7 +135,10 @@ export function ReconcileDateRangeCard() {
|
|||||||
<div className="p-3 flex gap-4">
|
<div className="p-3 flex gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
Date de début <span className="text-xs text-muted-foreground">(optionnel)</span>
|
Date de début{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(optionnel)
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="scale-90 origin-top-left">
|
<div className="scale-90 origin-top-left">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
@@ -233,8 +237,8 @@ export function ReconcileDateRangeCard() {
|
|||||||
) : (
|
) : (
|
||||||
<>jusqu'au {format(endDate, "PPP", { locale: fr })}</>
|
<>jusqu'au {format(endDate, "PPP", { locale: fr })}</>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
comme pointées. Seules les opérations non encore pointées seront
|
comme pointées. Seules les opérations non encore pointées
|
||||||
modifiées.
|
seront modifiées.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
@@ -257,4 +261,3 @@ export function ReconcileDateRangeCard() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
components/settings/theme-card.tsx
Normal file
93
components/settings/theme-card.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: "Clair",
|
||||||
|
icon: Sun,
|
||||||
|
description: "Thème clair",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: "Sombre",
|
||||||
|
icon: Moon,
|
||||||
|
description: "Thème sombre",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeCard() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Éviter le flash de contenu non stylé
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTheme = theme || "light";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="card-hover">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
Thème
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choisissez le thème d'affichage de l'application
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RadioGroup
|
||||||
|
value={currentTheme}
|
||||||
|
onValueChange={(value) => setTheme(value)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{THEMES.map((themeOption) => {
|
||||||
|
const Icon = themeOption.icon;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={themeOption.value}
|
||||||
|
htmlFor={`theme-${themeOption.value}`}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
|
currentTheme === themeOption.value
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={themeOption.value}
|
||||||
|
id={`theme-${themeOption.value}`}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<Icon className="w-6 h-6 mb-2" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{themeOption.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,11 +103,38 @@ export function BalanceLineChart({
|
|||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
content={({ active, payload }) => {
|
||||||
contentStyle={{
|
if (!active || !payload?.length) return null;
|
||||||
backgroundColor: "var(--card)",
|
return (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--popover)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
opacity: 1,
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`tooltip-${index}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ color: entry.color }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{entry.name}:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{formatCurrency(entry.value as number)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{mode === "aggregated" ? (
|
{mode === "aggregated" ? (
|
||||||
|
|||||||
@@ -166,11 +166,67 @@ export function CategoryTrendChart({
|
|||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
content={({ active, payload, label }) => {
|
||||||
contentStyle={{
|
if (!active || !payload?.length) return null;
|
||||||
backgroundColor: "var(--card)",
|
|
||||||
|
// Filtrer seulement les catégories qui ont une valeur
|
||||||
|
const entriesWithValue = payload.filter(
|
||||||
|
(entry) => entry.value !== undefined && entry.value !== null && Number(entry.value) !== 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entriesWithValue.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-2.5 py-2 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-foreground mb-1.5 pb-1 border-b border-border/50">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entriesWithValue.map((entry, index) => {
|
||||||
|
const categoryId = entry.dataKey as string;
|
||||||
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
|
const categoryName = getCategoryName(categoryId);
|
||||||
|
const value = entry.value as number;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`tooltip-${categoryId}-${index}`}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{categoryInfo ? (
|
||||||
|
<CategoryIcon
|
||||||
|
icon={categoryInfo.icon}
|
||||||
|
color={categoryInfo.color}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-foreground flex-1 min-w-0 truncate">
|
||||||
|
{categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatCurrency(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
opacity: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
@@ -179,7 +235,8 @@ export function CategoryTrendChart({
|
|||||||
const allCategoryIds = Array.from(categoryTotals.keys());
|
const allCategoryIds = Array.from(categoryTotals.keys());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-2">
|
<div className="max-h-[60px] overflow-y-auto overflow-x-hidden pr-2 mt-2">
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||||
{allCategoryIds.map((categoryId) => {
|
{allCategoryIds.map((categoryId) => {
|
||||||
const categoryInfo = getCategoryInfo(categoryId);
|
const categoryInfo = getCategoryInfo(categoryId);
|
||||||
const categoryName = getCategoryName(categoryId);
|
const categoryName = getCategoryName(categoryId);
|
||||||
@@ -234,6 +291,7 @@ export function CategoryTrendChart({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -259,6 +317,7 @@ export function CategoryTrendChart({
|
|||||||
strokeWidth={isSelected ? 2 : 1}
|
strokeWidth={isSelected ? 2 : 1}
|
||||||
strokeOpacity={isSelected ? 1 : 0.3}
|
strokeOpacity={isSelected ? 1 : 0.3}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
connectNulls={true}
|
||||||
hide={!isSelected && selectedCategories.length > 0}
|
hide={!isSelected && selectedCategories.length > 0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function MonthlyChart({
|
|||||||
// Formater les labels de manière plus compacte
|
// Formater les labels de manière plus compacte
|
||||||
const formatMonthLabel = (month: string) => {
|
const formatMonthLabel = (month: string) => {
|
||||||
// Format: "janv. 24" -> "janv 24" (enlever le point)
|
// Format: "janv. 24" -> "janv 24" (enlever le point)
|
||||||
return month.replace('.', '');
|
return month.replace(".", "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartContent = (
|
const chartContent = (
|
||||||
@@ -62,7 +62,15 @@ export function MonthlyChart({
|
|||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<div className="h-[400px] sm:h-[300px]">
|
<div className="h-[400px] sm:h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ left: 0, right: 10, top: 10, bottom: data.length > 6 ? 80 : 60 }}>
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
margin={{
|
||||||
|
left: 0,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: data.length > 6 ? 80 : 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="month"
|
dataKey="month"
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function StatsSummaryCards({
|
|||||||
<Card className="stat-card-textured relative overflow-hidden">
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingUp className="h-16 w-16 md:h-20 md:w-20 text-success" strokeWidth={1} />
|
<TrendingUp
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-success"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -41,7 +44,10 @@ export function StatsSummaryCards({
|
|||||||
<Card className="stat-card-textured relative overflow-hidden">
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<TrendingDown className="h-16 w-16 md:h-20 md:w-20 text-destructive" strokeWidth={1} />
|
<TrendingDown
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-destructive"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -58,7 +64,10 @@ export function StatsSummaryCards({
|
|||||||
<Card className="stat-card-textured relative overflow-hidden">
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<ArrowRight className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40" strokeWidth={1} />
|
<ArrowRight
|
||||||
|
className="h-16 w-16 md:h-20 md:w-20 text-muted-foreground/40"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
@@ -75,12 +84,12 @@ export function StatsSummaryCards({
|
|||||||
<Card className="stat-card-textured relative overflow-hidden">
|
<Card className="stat-card-textured relative overflow-hidden">
|
||||||
{/* Icône en arrière-plan */}
|
{/* Icône en arrière-plan */}
|
||||||
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
<div className="absolute bottom-2 right-2 opacity-[0.04] z-0 pointer-events-none">
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"h-16 w-16 md:h-20 md:w-20 rounded-full border-2",
|
"h-16 w-16 md:h-20 md:w-20 rounded-full border-2",
|
||||||
savings >= 0
|
savings >= 0 ? "border-success" : "border-destructive",
|
||||||
? "border-success"
|
)}
|
||||||
: "border-destructive"
|
/>
|
||||||
)} />
|
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
<CardHeader className="pb-3 px-5 pt-5 relative z-10">
|
||||||
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
<CardTitle className="text-[10px] md:text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -1,43 +1,324 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { List, ListOrdered } from "lucide-react";
|
||||||
import type { Transaction, Category } from "@/lib/types";
|
import type { Transaction, Category } from "@/lib/types";
|
||||||
|
|
||||||
interface TopExpensesListProps {
|
interface TopExpensesListProps {
|
||||||
|
expensesByCategory: Array<{
|
||||||
|
categoryId: string | null;
|
||||||
expenses: Transaction[];
|
expenses: Transaction[];
|
||||||
|
}>;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
|
allTransactions?: Transaction[]; // Toutes les transactions filtrées pour calculer toutes les dépenses
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopExpensesList({
|
export function TopExpensesList({
|
||||||
expenses,
|
expensesByCategory,
|
||||||
categories,
|
categories,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
allTransactions = [],
|
||||||
}: TopExpensesListProps) {
|
}: TopExpensesListProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const [showAllExpenses, setShowAllExpenses] = useState(false);
|
||||||
|
|
||||||
|
// Filtrer les catégories qui ont des dépenses
|
||||||
|
const categoriesWithExpenses = expensesByCategory.filter(
|
||||||
|
(group) => group.expenses.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasExpenses = categoriesWithExpenses.length > 0;
|
||||||
|
|
||||||
|
// Déterminer la valeur par défaut du premier onglet
|
||||||
|
const defaultTabValue =
|
||||||
|
categoriesWithExpenses.length > 0
|
||||||
|
? categoriesWithExpenses[0].categoryId || "uncategorized"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(() => defaultTabValue);
|
||||||
|
|
||||||
|
// Mettre à jour activeTab quand defaultTabValue change ou si activeTab est invalide
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultTabValue) return;
|
||||||
|
|
||||||
|
// Vérifier si activeTab correspond à une catégorie valide
|
||||||
|
const isValidTab = categoriesWithExpenses.some(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === activeTab,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si activeTab est vide ou invalide, utiliser defaultTabValue
|
||||||
|
if (!activeTab || !isValidTab) {
|
||||||
|
setActiveTab(defaultTabValue);
|
||||||
|
}
|
||||||
|
}, [defaultTabValue, categoriesWithExpenses, activeTab]);
|
||||||
|
|
||||||
|
// Calculer les données du graphique pour la catégorie active
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!hasExpenses) return [];
|
||||||
|
|
||||||
|
// Utiliser activeTab ou defaultTabValue comme fallback
|
||||||
|
const currentTab = activeTab || defaultTabValue;
|
||||||
|
if (!currentTab) return [];
|
||||||
|
|
||||||
|
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeCategoryGroup) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si showAllExpenses est activé et qu'on a toutes les transactions, utiliser toutes les dépenses de la catégorie
|
||||||
|
let expenses: Transaction[];
|
||||||
|
if (showAllExpenses && allTransactions.length > 0) {
|
||||||
|
// Filtrer toutes les transactions pour obtenir toutes les dépenses de cette catégorie parente
|
||||||
|
const categoryId = activeCategoryGroup.categoryId;
|
||||||
|
expenses = allTransactions
|
||||||
|
.filter((t) => t.amount < 0) // Seulement les dépenses
|
||||||
|
.filter((t) => {
|
||||||
|
if (categoryId === null) {
|
||||||
|
return !t.categoryId;
|
||||||
|
}
|
||||||
|
// Vérifier si la transaction appartient à cette catégorie parente ou ses sous-catégories
|
||||||
|
const category = categories.find((c) => c.id === t.categoryId);
|
||||||
|
if (!category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const transactionGroupId = category.parentId || category.id;
|
||||||
|
return transactionGroupId === categoryId;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Utiliser seulement les top 10
|
||||||
|
expenses = activeCategoryGroup.expenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenses.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouper les dépenses par période
|
||||||
|
// Si moins de 30 jours, groupe par jour
|
||||||
|
// Si moins de 6 mois, groupe par semaine
|
||||||
|
// Sinon groupe par mois
|
||||||
|
const sortedExpenses = [...expenses].sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sortedExpenses.length === 0) return [];
|
||||||
|
|
||||||
|
const firstDate = new Date(sortedExpenses[0].date);
|
||||||
|
const lastDate = new Date(
|
||||||
|
sortedExpenses[sortedExpenses.length - 1].date,
|
||||||
|
);
|
||||||
|
const daysDiff =
|
||||||
|
(lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
let groupBy: "day" | "week" | "month";
|
||||||
|
if (daysDiff <= 30) {
|
||||||
|
groupBy = "day";
|
||||||
|
} else if (daysDiff <= 180) {
|
||||||
|
groupBy = "week";
|
||||||
|
} else {
|
||||||
|
groupBy = "month";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour obtenir la clé de période d'une date
|
||||||
|
const getPeriodKey = (date: Date): { key: string; dateKey: string } => {
|
||||||
|
if (groupBy === "day") {
|
||||||
|
return {
|
||||||
|
key: date.toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}),
|
||||||
|
dateKey: date.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||||
|
};
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
// Semaine commençant le lundi
|
||||||
|
const weekStart = new Date(date);
|
||||||
|
const day = weekStart.getDay();
|
||||||
|
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
weekStart.setDate(diff);
|
||||||
|
return {
|
||||||
|
key: `Sem. ${weekStart.toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
})}`,
|
||||||
|
dateKey: weekStart.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
key: date.toLocaleDateString("fr-FR", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
dateKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, // YYYY-MM
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grouper les dépenses par période
|
||||||
|
const groupedData = new Map<
|
||||||
|
string,
|
||||||
|
{ total: number; dateKey: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
sortedExpenses.forEach((expense) => {
|
||||||
|
const date = new Date(expense.date);
|
||||||
|
const { key, dateKey } = getPeriodKey(date);
|
||||||
|
|
||||||
|
const current = groupedData.get(key);
|
||||||
|
if (current) {
|
||||||
|
current.total += Math.abs(expense.amount);
|
||||||
|
} else {
|
||||||
|
groupedData.set(key, {
|
||||||
|
total: Math.abs(expense.amount),
|
||||||
|
dateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer toutes les périodes entre la première et la dernière date
|
||||||
|
const allPeriodsMap = new Map<
|
||||||
|
string,
|
||||||
|
{ period: string; montant: number; dateKey: string }
|
||||||
|
>();
|
||||||
|
const currentDate = new Date(firstDate);
|
||||||
|
const endDate = new Date(lastDate);
|
||||||
|
|
||||||
|
// Normaliser les dates selon le type de groupement
|
||||||
|
if (groupBy === "day") {
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
// Début de la semaine de la première date
|
||||||
|
const day = currentDate.getDay();
|
||||||
|
const diff = currentDate.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
currentDate.setDate(diff);
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
// Début de la semaine de la dernière date (pour la boucle)
|
||||||
|
const lastDay = endDate.getDay();
|
||||||
|
const lastDiff = endDate.getDate() - lastDay + (lastDay === 0 ? -6 : 1);
|
||||||
|
endDate.setDate(lastDiff);
|
||||||
|
endDate.setHours(0, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
// Mois : premier jour du mois
|
||||||
|
currentDate.setDate(1);
|
||||||
|
currentDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate.setDate(1);
|
||||||
|
endDate.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const { key, dateKey } = getPeriodKey(currentDate);
|
||||||
|
const existingData = groupedData.get(key);
|
||||||
|
|
||||||
|
// Utiliser dateKey comme clé unique pour éviter les doublons
|
||||||
|
if (!allPeriodsMap.has(dateKey)) {
|
||||||
|
allPeriodsMap.set(dateKey, {
|
||||||
|
period: key,
|
||||||
|
montant: existingData ? Math.round(existingData.total) : 0,
|
||||||
|
dateKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passer à la période suivante
|
||||||
|
if (groupBy === "day") {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
currentDate.setDate(currentDate.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
// Mois suivant
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(allPeriodsMap.values()) .sort((a, b) =>
|
||||||
|
a.dateKey.localeCompare(b.dateKey),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
hasExpenses,
|
||||||
|
categoriesWithExpenses,
|
||||||
|
activeTab,
|
||||||
|
defaultTabValue,
|
||||||
|
showAllExpenses,
|
||||||
|
allTransactions,
|
||||||
|
categories,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="card-hover">
|
<Card className="card-hover">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle>
|
<CardTitle className="text-sm md:text-base">
|
||||||
|
Top 10 dépenses par top 5 catégories parentes
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{expenses.length > 0 ? (
|
{hasExpenses ? (
|
||||||
<div className="space-y-3 md:space-y-4">
|
<Tabs
|
||||||
{expenses.map((expense, index) => {
|
defaultValue={defaultTabValue}
|
||||||
const category = categories.find(
|
value={activeTab || defaultTabValue}
|
||||||
(c) => c.id === expense.categoryId,
|
onValueChange={setActiveTab}
|
||||||
);
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full flex-wrap h-auto p-1 mb-4">
|
||||||
|
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
|
||||||
|
const category = categoryId
|
||||||
|
? categories.find((c) => c.id === categoryId)
|
||||||
|
: null;
|
||||||
|
const tabValue = categoryId || "uncategorized";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tabValue}
|
||||||
|
value={tabValue}
|
||||||
|
className="flex items-center gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||||
|
>
|
||||||
|
{category && (
|
||||||
|
<CategoryIcon
|
||||||
|
icon={category.icon}
|
||||||
|
color={category.color}
|
||||||
|
size={isMobile ? 12 : 14}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-[100px] md:max-w-none">
|
||||||
|
{category?.name || "Non catégorisé"}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
|
||||||
|
const category = categoryId
|
||||||
|
? categories.find((c) => c.id === categoryId)
|
||||||
|
: null;
|
||||||
|
const tabValue = categoryId || "uncategorized";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={tabValue} value={tabValue}>
|
||||||
|
<div className="space-y-2 md:space-y-3">
|
||||||
|
{expenses.map((expense, index) => (
|
||||||
<div
|
<div
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
className="flex items-start gap-2 md:gap-3"
|
className="flex items-start gap-2 md:gap-3"
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0">
|
<div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-muted flex items-center justify-center text-[10px] md:text-xs font-semibold shrink-0">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -51,39 +332,137 @@ export function TopExpensesList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
|
||||||
<span className="text-[10px] md:text-xs text-muted-foreground">
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
||||||
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
{new Date(expense.date).toLocaleDateString(
|
||||||
|
"fr-FR",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{category && (
|
{expense.categoryId &&
|
||||||
|
(() => {
|
||||||
|
const expenseCategory = categories.find(
|
||||||
|
(c) => c.id === expense.categoryId,
|
||||||
|
);
|
||||||
|
// Afficher seulement si c'est une sous-catégorie (a un parentId)
|
||||||
|
if (expenseCategory?.parentId) {
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/transactions?categoryIds=${category.id}`}
|
href={`/transactions?categoryIds=${expenseCategory.id}`}
|
||||||
className="inline-block"
|
className="inline-block"
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
|
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${category.color}20`,
|
backgroundColor: `${expenseCategory.color}20`,
|
||||||
color: category.color,
|
color: expenseCategory.color,
|
||||||
borderColor: `${category.color}30`,
|
borderColor: `${expenseCategory.color}30`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
icon={category.icon}
|
icon={expenseCategory.icon}
|
||||||
color={category.color}
|
color={expenseCategory.color}
|
||||||
size={isMobile ? 8 : 10}
|
size={isMobile ? 8 : 10}
|
||||||
/>
|
/>
|
||||||
<span className="truncate max-w-[120px] md:max-w-none">
|
<span className="truncate max-w-[100px] md:max-w-none">
|
||||||
{category.name}
|
{expenseCategory.name}
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Graphique d'évolution temporelle */}
|
||||||
|
{chartData.length > 0 && (() => {
|
||||||
|
const currentTab = activeTab || defaultTabValue;
|
||||||
|
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||||
|
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||||
|
);
|
||||||
|
const activeCategory = activeCategoryGroup?.categoryId
|
||||||
|
? categories.find((c) => c.id === activeCategoryGroup.categoryId)
|
||||||
|
: null;
|
||||||
|
const barColor = activeCategory?.color || "var(--destructive)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
Évolution des dépenses dans le temps
|
||||||
|
</h3>
|
||||||
|
{allTransactions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAllExpenses(!showAllExpenses)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{showAllExpenses ? (
|
||||||
|
<>
|
||||||
|
<ListOrdered className="w-3 h-3 mr-1.5" />
|
||||||
|
Top 10 seulement
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<List className="w-3 h-3 mr-1.5" />
|
||||||
|
Voir toutes les dépenses
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
className="stroke-muted"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
className="text-xs"
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-xs"
|
||||||
|
width={80}
|
||||||
|
tickFormatter={(v) => {
|
||||||
|
if (Math.abs(v) >= 1000) {
|
||||||
|
return `${(v / 1000).toFixed(1)}k€`;
|
||||||
|
}
|
||||||
|
return `${Math.round(v)}€`;
|
||||||
|
}}
|
||||||
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="montant"
|
||||||
|
fill={barColor}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})()}
|
||||||
</div>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
||||||
Pas de dépenses pour cette période
|
Pas de dépenses pour cette période
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function TransactionTable({
|
|||||||
setFocusedIndex(index);
|
setFocusedIndex(index);
|
||||||
onMarkReconciled(transactionId);
|
onMarkReconciled(transactionId);
|
||||||
},
|
},
|
||||||
[onMarkReconciled]
|
[onMarkReconciled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -198,7 +198,7 @@ export function TransactionTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
[focusedIndex, transactions, onMarkReconciled, virtualizer],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -215,7 +215,7 @@ export function TransactionTable({
|
|||||||
(accountId: string) => {
|
(accountId: string) => {
|
||||||
return accounts.find((a) => a.id === accountId);
|
return accounts.find((a) => a.id === accountId);
|
||||||
},
|
},
|
||||||
[accounts]
|
[accounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCategory = useCallback(
|
const getCategory = useCallback(
|
||||||
@@ -223,7 +223,7 @@ export function TransactionTable({
|
|||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
return categories.find((c) => c.id === categoryId);
|
return categories.find((c) => c.id === categoryId);
|
||||||
},
|
},
|
||||||
[categories]
|
[categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -281,7 +281,7 @@ export function TransactionTable({
|
|||||||
"p-4 md:p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
"p-4 md:p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||||
transaction.isReconciled && "bg-emerald-500/5",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
isDuplicate && "shadow-sm"
|
isDuplicate && "shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -324,7 +324,7 @@ export function TransactionTable({
|
|||||||
"font-semibold tabular-nums text-base md:text-base shrink-0",
|
"font-semibold tabular-nums text-base md:text-base shrink-0",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-destructive"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -359,7 +359,7 @@ export function TransactionTable({
|
|||||||
showBadge
|
showBadge
|
||||||
align="start"
|
align="start"
|
||||||
disabled={updatingTransactionIds.has(
|
disabled={updatingTransactionIds.has(
|
||||||
transaction.id
|
transaction.id,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +392,7 @@ export function TransactionTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onDelete(transaction.id);
|
onDelete(transaction.id);
|
||||||
@@ -508,7 +508,7 @@ export function TransactionTable({
|
|||||||
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
||||||
transaction.isReconciled && "bg-emerald-500/5",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||||
isDuplicate && "shadow-sm"
|
isDuplicate && "shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -577,7 +577,7 @@ export function TransactionTable({
|
|||||||
"p-3 text-right font-semibold tabular-nums",
|
"p-3 text-right font-semibold tabular-nums",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
? "text-success"
|
? "text-success"
|
||||||
: "text-destructive"
|
: "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
@@ -644,7 +644,7 @@ export function TransactionTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onDelete(transaction.id);
|
onDelete(transaction.id);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
|
|||||||
@@ -99,10 +99,7 @@ export function CategoryFilterCombobox({
|
|||||||
if (isAll || isUncategorized) {
|
if (isAll || isUncategorized) {
|
||||||
// Start fresh with this category and its children (if parent)
|
// Start fresh with this category and its children (if parent)
|
||||||
if (isParentCategory && childCategories.length > 0) {
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
newSelection = [
|
newSelection = [newValue, ...childCategories.map((child) => child.id)];
|
||||||
newValue,
|
|
||||||
...childCategories.map((child) => child.id),
|
|
||||||
];
|
|
||||||
} else {
|
} else {
|
||||||
newSelection = [newValue];
|
newSelection = [newValue];
|
||||||
}
|
}
|
||||||
@@ -111,7 +108,7 @@ export function CategoryFilterCombobox({
|
|||||||
if (isParentCategory && childCategories.length > 0) {
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
const childIds = childCategories.map((child) => child.id);
|
const childIds = childCategories.map((child) => child.id);
|
||||||
newSelection = value.filter(
|
newSelection = value.filter(
|
||||||
(v) => v !== newValue && !childIds.includes(v)
|
(v) => v !== newValue && !childIds.includes(v),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
newSelection = value.filter((v) => v !== newValue);
|
newSelection = value.filter((v) => v !== newValue);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function Checkbox({
|
|||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/30 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
|||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -61,7 +61,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -96,7 +96,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
"focus-visible:border-primary/50 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-md focus-visible:shadow-primary/10",
|
||||||
"hover:border-primary/30 hover:shadow-sm",
|
"hover:border-primary/30 hover:shadow-sm",
|
||||||
"aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/30 aria-invalid:border-destructive",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
|||||||
"bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
"bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||||
"[&_svg:not([class*='size-'])]:size-3",
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
"[[data-slot=tooltip-content]_&]:bg-background/15 [[data-slot=tooltip-content]_&]:text-background",
|
"[[data-slot=tooltip-content]_&]:bg-background/15 [[data-slot=tooltip-content]_&]:text-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useTheme } from "next-themes";
|
|||||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme();
|
const { theme = "light" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme === "system" ? "dark" : (theme as ToasterProps["theme"])}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useState } from "react";
|
|||||||
*/
|
*/
|
||||||
export function useLocalStorage<T>(
|
export function useLocalStorage<T>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T
|
initialValue: T,
|
||||||
): [T, (value: T | ((val: T) => T)) => void] {
|
): [T, (value: T | ((val: T) => T)) => void] {
|
||||||
// État pour stocker la valeur
|
// État pour stocker la valeur
|
||||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
@@ -42,4 +42,3 @@ export function useLocalStorage<T>(
|
|||||||
|
|
||||||
return [storedValue, setValue];
|
return [storedValue, setValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,13 +146,13 @@ export function useTransactionsBalanceChart({
|
|||||||
// Fetch transactions before startDate for initial balance calculation
|
// Fetch transactions before startDate for initial balance calculation
|
||||||
const { data: beforeStartDateData } = useTransactions(
|
const { data: beforeStartDateData } = useTransactions(
|
||||||
beforeStartDateParams,
|
beforeStartDateParams,
|
||||||
!!metadata && period !== "all" && !!startDate
|
!!metadata && period !== "all" && !!startDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch all filtered transactions for chart
|
// Fetch all filtered transactions for chart
|
||||||
const { data: transactionsData, isLoading } = useTransactions(
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
chartParams,
|
chartParams,
|
||||||
!!metadata
|
!!metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate balance chart data
|
// Calculate balance chart data
|
||||||
@@ -169,7 +169,7 @@ export function useTransactionsBalanceChart({
|
|||||||
|
|
||||||
// Sort transactions by date
|
// Sort transactions by date
|
||||||
const sortedTransactions = [...transactions].sort(
|
const sortedTransactions = [...transactions].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate starting balance: initialBalance + transactions before startDate
|
// Calculate starting balance: initialBalance + transactions before startDate
|
||||||
@@ -181,7 +181,7 @@ export function useTransactionsBalanceChart({
|
|||||||
// Start with initial balances
|
// Start with initial balances
|
||||||
runningBalance = accountsToUse.reduce(
|
runningBalance = accountsToUse.reduce(
|
||||||
(sum: number, acc: Account) => sum + (acc.initialBalance || 0),
|
(sum: number, acc: Account) => sum + (acc.initialBalance || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add transactions before startDate if we have them
|
// Add transactions before startDate if we have them
|
||||||
@@ -190,7 +190,7 @@ export function useTransactionsBalanceChart({
|
|||||||
(t) => {
|
(t) => {
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
return transactionDate < startDate;
|
return transactionDate < startDate;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
beforeStartTransactions.forEach((t) => {
|
beforeStartTransactions.forEach((t) => {
|
||||||
runningBalance += t.amount;
|
runningBalance += t.amount;
|
||||||
@@ -206,7 +206,7 @@ export function useTransactionsBalanceChart({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
|
const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
|
||||||
aggregatedBalanceByDate.entries()
|
aggregatedBalanceByDate.entries(),
|
||||||
).map(([date, balance]) => ({
|
).map(([date, balance]) => ({
|
||||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -234,7 +234,7 @@ export function useTransactionsBalanceChart({
|
|||||||
(t) => {
|
(t) => {
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
return transactionDate < startDate;
|
return transactionDate < startDate;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
beforeStartTransactions.forEach((t) => {
|
beforeStartTransactions.forEach((t) => {
|
||||||
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
||||||
@@ -246,7 +246,7 @@ export function useTransactionsBalanceChart({
|
|||||||
const transactionsForAccounts = selectedAccounts.includes("all")
|
const transactionsForAccounts = selectedAccounts.includes("all")
|
||||||
? sortedTransactions
|
? sortedTransactions
|
||||||
: sortedTransactions.filter((t) =>
|
: sortedTransactions.filter((t) =>
|
||||||
selectedAccounts.includes(t.accountId)
|
selectedAccounts.includes(t.accountId),
|
||||||
);
|
);
|
||||||
|
|
||||||
transactionsForAccounts.forEach((t) => {
|
transactionsForAccounts.forEach((t) => {
|
||||||
@@ -276,7 +276,7 @@ export function useTransactionsBalanceChart({
|
|||||||
(t) => {
|
(t) => {
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
return transactionDate < startDate && t.accountId === account.id;
|
return transactionDate < startDate && t.accountId === account.id;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
beforeStartTransactions.forEach((t) => {
|
beforeStartTransactions.forEach((t) => {
|
||||||
accountStartingBalance += t.amount;
|
accountStartingBalance += t.amount;
|
||||||
@@ -304,7 +304,7 @@ export function useTransactionsBalanceChart({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return point;
|
return point;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function useTransactionsChartData({
|
|||||||
// Fetch all filtered transactions for chart
|
// Fetch all filtered transactions for chart
|
||||||
const { data: transactionsData, isLoading } = useTransactions(
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
chartParams,
|
chartParams,
|
||||||
!!metadata
|
!!metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate monthly chart data
|
// Calculate monthly chart data
|
||||||
@@ -141,7 +141,7 @@ export function useTransactionsChartData({
|
|||||||
|
|
||||||
// Format months with year: use short format for better readability
|
// Format months with year: use short format for better readability
|
||||||
const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) =>
|
const sortedMonths = Array.from(monthlyMap.entries()).sort((a, b) =>
|
||||||
a[0].localeCompare(b[0])
|
a[0].localeCompare(b[0]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const monthlyChartData: MonthlyChartData[] = sortedMonths.map(
|
const monthlyChartData: MonthlyChartData[] = sortedMonths.map(
|
||||||
@@ -160,7 +160,7 @@ export function useTransactionsChartData({
|
|||||||
depenses: values.expenses,
|
depenses: values.expenses,
|
||||||
solde: values.income - values.expenses,
|
solde: values.income - values.expenses,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return monthlyChartData;
|
return monthlyChartData;
|
||||||
@@ -184,10 +184,12 @@ export function useTransactionsChartData({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const categoryChartData: CategoryChartData[] = Array.from(
|
const categoryChartData: CategoryChartData[] = Array.from(
|
||||||
categoryTotals.entries()
|
categoryTotals.entries(),
|
||||||
)
|
)
|
||||||
.map(([categoryId, total]) => {
|
.map(([categoryId, total]) => {
|
||||||
const category = metadata.categories.find((c: Category) => c.id === categoryId);
|
const category = metadata.categories.find(
|
||||||
|
(c: Category) => c.id === categoryId,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
name: category?.name || "Non catégorisé",
|
name: category?.name || "Non catégorisé",
|
||||||
value: Math.round(total),
|
value: Math.round(total),
|
||||||
@@ -204,10 +206,7 @@ export function useTransactionsChartData({
|
|||||||
// Calculate total amount and count from all filtered transactions
|
// Calculate total amount and count from all filtered transactions
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
if (!transactionsData) return 0;
|
if (!transactionsData) return 0;
|
||||||
return transactionsData.transactions.reduce(
|
return transactionsData.transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||||
(sum, t) => sum + t.amount,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}, [transactionsData]);
|
}, [transactionsData]);
|
||||||
|
|
||||||
const totalCount = useMemo(() => {
|
const totalCount = useMemo(() => {
|
||||||
@@ -220,6 +219,6 @@ export function useTransactionsChartData({
|
|||||||
isLoading,
|
isLoading,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
transactions: transactionsData?.transactions || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function useTransactionsForAccountFilter({
|
|||||||
// Fetch all filtered transactions (without account filter)
|
// Fetch all filtered transactions (without account filter)
|
||||||
const { data: transactionsData, isLoading } = useTransactions(
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
filterParams,
|
filterParams,
|
||||||
!!metadata
|
!!metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -106,4 +106,3 @@ export function useTransactionsForAccountFilter({
|
|||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function useTransactionsForCategoryFilter({
|
|||||||
// Fetch all filtered transactions (without category filter)
|
// Fetch all filtered transactions (without category filter)
|
||||||
const { data: transactionsData, isLoading } = useTransactions(
|
const { data: transactionsData, isLoading } = useTransactions(
|
||||||
filterParams,
|
filterParams,
|
||||||
!!metadata
|
!!metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -102,4 +102,3 @@ export function useTransactionsForCategoryFilter({
|
|||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function useTransactionsPage() {
|
|||||||
"all",
|
"all",
|
||||||
]);
|
]);
|
||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||||
const [period, setPeriod] = useLocalStorage<Period>("transactions-period", "3months");
|
const [period, setPeriod] = useLocalStorage<Period>(
|
||||||
|
"transactions-period",
|
||||||
|
"3months",
|
||||||
|
);
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -85,12 +88,12 @@ export function useTransactionsPage() {
|
|||||||
categoryIds.forEach((categoryId) => {
|
categoryIds.forEach((categoryId) => {
|
||||||
// Check if this is a parent category
|
// Check if this is a parent category
|
||||||
const category = metadata.categories.find(
|
const category = metadata.categories.find(
|
||||||
(c: Category) => c.id === categoryId
|
(c: Category) => c.id === categoryId,
|
||||||
);
|
);
|
||||||
if (category && category.parentId === null) {
|
if (category && category.parentId === null) {
|
||||||
// Find all children of this parent
|
// Find all children of this parent
|
||||||
const children = metadata.categories.filter(
|
const children = metadata.categories.filter(
|
||||||
(c: Category) => c.parentId === categoryId
|
(c: Category) => c.parentId === categoryId,
|
||||||
);
|
);
|
||||||
children.forEach((child: Category) => {
|
children.forEach((child: Category) => {
|
||||||
expandedCategoryIds.add(child.id);
|
expandedCategoryIds.add(child.id);
|
||||||
@@ -205,7 +208,8 @@ export function useTransactionsPage() {
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePeriodChange = useCallback((p: Period) => {
|
const handlePeriodChange = useCallback(
|
||||||
|
(p: Period) => {
|
||||||
setPeriod(p);
|
setPeriod(p);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
if (p !== "custom") {
|
if (p !== "custom") {
|
||||||
@@ -213,7 +217,9 @@ export function useTransactionsPage() {
|
|||||||
} else {
|
} else {
|
||||||
setIsCustomDatePickerOpen(true);
|
setIsCustomDatePickerOpen(true);
|
||||||
}
|
}
|
||||||
}, [setPeriod]);
|
},
|
||||||
|
[setPeriod],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCustomStartDateChange = useCallback((date: Date | undefined) => {
|
const handleCustomStartDateChange = useCallback((date: Date | undefined) => {
|
||||||
setCustomStartDate(date);
|
setCustomStartDate(date);
|
||||||
|
|||||||
6637
pnpm-lock.yaml
generated
6637
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,12 @@ async function main() {
|
|||||||
const accountNumbers = process.argv.slice(2);
|
const accountNumbers = process.argv.slice(2);
|
||||||
|
|
||||||
if (accountNumbers.length === 0) {
|
if (accountNumbers.length === 0) {
|
||||||
console.error("Usage: tsx scripts/fix-account-balances.ts <accountNumber1> [accountNumber2] ...");
|
console.error(
|
||||||
console.error("Exemple: tsx scripts/fix-account-balances.ts 0748461N022 7555880857A");
|
"Usage: tsx scripts/fix-account-balances.ts <accountNumber1> [accountNumber2] ...",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"Exemple: tsx scripts/fix-account-balances.ts 0748461N022 7555880857A",
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +51,9 @@ async function main() {
|
|||||||
console.log(` Balance calculée: ${calculatedBalance}`);
|
console.log(` Balance calculée: ${calculatedBalance}`);
|
||||||
|
|
||||||
if (Math.abs(account.balance - calculatedBalance) > 0.01) {
|
if (Math.abs(account.balance - calculatedBalance) > 0.01) {
|
||||||
console.log(`\n⚠️ Différence détectée: ${Math.abs(account.balance - calculatedBalance).toFixed(2)}`);
|
console.log(
|
||||||
|
`\n⚠️ Différence détectée: ${Math.abs(account.balance - calculatedBalance).toFixed(2)}`,
|
||||||
|
);
|
||||||
console.log(`Mise à jour du solde...`);
|
console.log(`Mise à jour du solde...`);
|
||||||
|
|
||||||
await prisma.account.update({
|
await prisma.account.update({
|
||||||
@@ -68,9 +74,7 @@ async function main() {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main().catch((e) => {
|
||||||
.catch((e) => {
|
|
||||||
console.error("Erreur:", e);
|
console.error("Erreur:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ async function main() {
|
|||||||
const accountNumber = process.argv[2];
|
const accountNumber = process.argv[2];
|
||||||
|
|
||||||
if (!accountNumber) {
|
if (!accountNumber) {
|
||||||
console.error("Usage: tsx scripts/merge-duplicate-accounts.ts <accountNumber>");
|
console.error(
|
||||||
|
"Usage: tsx scripts/merge-duplicate-accounts.ts <accountNumber>",
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +54,19 @@ async function main() {
|
|||||||
|
|
||||||
// Le compte avec bankId numérique (pas "FR") est le bon - le garder comme principal
|
// Le compte avec bankId numérique (pas "FR") est le bon - le garder comme principal
|
||||||
// Si pas de bankId numérique, garder le plus récent
|
// Si pas de bankId numérique, garder le plus récent
|
||||||
const primaryAccount = accounts.find(acc => acc.bankId !== "FR" && acc.bankId !== "") || accounts[0];
|
const primaryAccount =
|
||||||
const accountsToMerge = accounts.filter(acc => acc.id !== primaryAccount.id);
|
accounts.find((acc) => acc.bankId !== "FR" && acc.bankId !== "") ||
|
||||||
|
accounts[0];
|
||||||
|
const accountsToMerge = accounts.filter(
|
||||||
|
(acc) => acc.id !== primaryAccount.id,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`\nCompte principal (conservé): ${primaryAccount.id} (bankId: ${primaryAccount.bankId})`);
|
console.log(
|
||||||
console.log(`Comptes à fusionner: ${accountsToMerge.map((a) => `${a.id} (bankId: ${a.bankId})`).join(", ")}\n`);
|
`\nCompte principal (conservé): ${primaryAccount.id} (bankId: ${primaryAccount.bankId})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Comptes à fusionner: ${accountsToMerge.map((a) => `${a.id} (bankId: ${a.bankId})`).join(", ")}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
// Calculer l'initialBalance total (somme des initialBalance)
|
// Calculer l'initialBalance total (somme des initialBalance)
|
||||||
const totalInitialBalance = accounts.reduce(
|
const totalInitialBalance = accounts.reduce(
|
||||||
@@ -111,7 +121,9 @@ async function main() {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Balance calculée à partir des transactions: ${calculatedBalance}`);
|
console.log(
|
||||||
|
`Balance calculée à partir des transactions: ${calculatedBalance}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Mettre à jour la balance du compte principal
|
// Mettre à jour la balance du compte principal
|
||||||
// Garder le bankId du compte principal (celui qui est correct)
|
// Garder le bankId du compte principal (celui qui est correct)
|
||||||
@@ -125,12 +137,14 @@ async function main() {
|
|||||||
// Garder le bankId du compte principal (le bon)
|
// Garder le bankId du compte principal (le bon)
|
||||||
bankId: primaryAccount.bankId,
|
bankId: primaryAccount.bankId,
|
||||||
// Garder le dernier import le plus récent parmi tous les comptes
|
// Garder le dernier import le plus récent parmi tous les comptes
|
||||||
lastImport:
|
lastImport: accounts.reduce(
|
||||||
accounts.reduce((latest, acc) => {
|
(latest, acc) => {
|
||||||
if (!acc.lastImport) return latest;
|
if (!acc.lastImport) return latest;
|
||||||
if (!latest) return acc.lastImport;
|
if (!latest) return acc.lastImport;
|
||||||
return acc.lastImport > latest ? acc.lastImport : latest;
|
return acc.lastImport > latest ? acc.lastImport : latest;
|
||||||
}, null as string | null),
|
},
|
||||||
|
null as string | null,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +166,9 @@ async function main() {
|
|||||||
|
|
||||||
if (finalAccount) {
|
if (finalAccount) {
|
||||||
console.log(`\nVérification finale:`);
|
console.log(`\nVérification finale:`);
|
||||||
console.log(` - Transactions dans le compte: ${finalAccount.transactions.length}`);
|
console.log(
|
||||||
|
` - Transactions dans le compte: ${finalAccount.transactions.length}`,
|
||||||
|
);
|
||||||
console.log(` - Balance: ${finalAccount.balance}`);
|
console.log(` - Balance: ${finalAccount.balance}`);
|
||||||
console.log(` - Bank ID: ${finalAccount.bankId}`);
|
console.log(` - Bank ID: ${finalAccount.bankId}`);
|
||||||
}
|
}
|
||||||
@@ -160,9 +176,7 @@ async function main() {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main().catch((e) => {
|
||||||
.catch((e) => {
|
|
||||||
console.error("Erreur:", e);
|
console.error("Erreur:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -396,7 +396,9 @@ export const bankingService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return accounts.map(
|
return accounts.map(
|
||||||
(a): Account & { transactionCount: number; calculatedBalance: number } => ({
|
(
|
||||||
|
a,
|
||||||
|
): Account & { transactionCount: number; calculatedBalance: number } => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
bankId: a.bankId,
|
bankId: a.bankId,
|
||||||
|
|||||||
Reference in New Issue
Block a user