From 66db34c712300ff4b5dbe7e06246f16d6f6a8469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elisi=C3=A1rio=20Couto?= Date: Mon, 15 Sep 2025 01:30:34 +0100 Subject: [PATCH] feat(frontend): Complete shadcn/ui migration with dark mode support and analytics updates. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert all analytics components to use shadcn Card and semantic colors - Update RawTransactionModal with proper shadcn styling and theme support - Fix all remaining hardcoded colors to use CSS variables (bg-card, text-foreground, etc.) - Ensure consistent theming across light/dark modes for all components - Add custom tooltips with semantic colors for chart components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/App.tsx | 23 - frontend/src/components/AccountsOverview.tsx | 464 +++++++++--------- frontend/src/components/Dashboard.tsx | 197 -------- frontend/src/components/Header.tsx | 16 +- frontend/src/components/Notifications.tsx | 400 +++++++-------- .../src/components/RawTransactionModal.tsx | 47 +- frontend/src/components/Sidebar.tsx | 29 +- .../src/components/TransactionSkeleton.tsx | 2 +- frontend/src/components/TransactionsTable.tsx | 364 ++++++-------- .../src/components/analytics/BalanceChart.tsx | 88 +++- .../components/analytics/MonthlyTrends.tsx | 27 +- .../src/components/analytics/StatCard.tsx | 76 +-- .../components/analytics/TimePeriodFilter.tsx | 18 +- .../analytics/TransactionDistribution.tsx | 41 +- .../components/filters/AccountCombobox.tsx | 14 +- .../components/filters/ActiveFilterChips.tsx | 12 +- .../filters/AdvancedFiltersPopover.tsx | 9 +- .../components/filters/DateRangePicker.tsx | 16 +- frontend/src/components/filters/FilterBar.tsx | 15 +- frontend/src/components/filters/index.ts | 12 +- frontend/src/components/ui/alert.tsx | 59 +++ frontend/src/components/ui/badge.tsx | 14 +- frontend/src/components/ui/button.tsx | 26 +- frontend/src/components/ui/calendar.tsx | 76 +-- frontend/src/components/ui/card.tsx | 86 ++++ frontend/src/components/ui/command.tsx | 64 +-- .../components/ui/data-table-pagination.tsx | 137 ++++++ frontend/src/components/ui/dialog.tsx | 52 +- frontend/src/components/ui/input.tsx | 16 +- frontend/src/components/ui/label.tsx | 19 + frontend/src/components/ui/pagination.tsx | 118 +++++ frontend/src/components/ui/popover.tsx | 20 +- frontend/src/components/ui/select.tsx | 56 +-- frontend/src/components/ui/table.tsx | 117 +++++ frontend/src/components/ui/theme-toggle.tsx | 52 ++ frontend/src/contexts/ThemeContext.tsx | 76 +++ frontend/src/index.css | 4 +- frontend/src/lib/api.ts | 47 +- frontend/src/lib/timePeriods.ts | 2 +- frontend/src/lib/utils.ts | 11 +- frontend/src/main.tsx | 5 +- frontend/src/routes/__root.tsx | 4 +- frontend/src/routes/analytics.tsx | 52 +- frontend/tailwind.config.js | 102 ++-- 44 files changed, 1790 insertions(+), 1295 deletions(-) delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/components/Dashboard.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/data-table-pagination.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/pagination.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/theme-toggle.tsx create mode 100644 frontend/src/contexts/ThemeContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 826e730..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import Dashboard from "./components/Dashboard"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - }, - }, -}); - -function App() { - return ( - -
- -
-
- ); -} - -export default App; diff --git a/frontend/src/components/AccountsOverview.tsx b/frontend/src/components/AccountsOverview.tsx index cbb2e79..e3ddb74 100644 --- a/frontend/src/components/AccountsOverview.tsx +++ b/frontend/src/components/AccountsOverview.tsx @@ -13,38 +13,47 @@ import { } from "lucide-react"; import { apiClient } from "../lib/api"; import { formatCurrency, formatDate } from "../lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Button } from "./ui/button"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import LoadingSpinner from "./LoadingSpinner"; import type { Account, Balance } from "../types/api"; // Helper function to get status indicator color and styles const getStatusIndicator = (status: string) => { const statusLower = status.toLowerCase(); - + switch (statusLower) { - case 'ready': + case "ready": return { - color: 'bg-green-500', - tooltip: 'Ready', + color: "bg-green-500", + tooltip: "Ready", }; - case 'pending': + case "pending": return { - color: 'bg-yellow-500', - tooltip: 'Pending', + color: "bg-yellow-500", + tooltip: "Pending", }; - case 'error': - case 'failed': + case "error": + case "failed": return { - color: 'bg-red-500', - tooltip: 'Error', + color: "bg-red-500", + tooltip: "Error", }; - case 'inactive': + case "inactive": return { - color: 'bg-gray-500', - tooltip: 'Inactive', + color: "bg-gray-500", + tooltip: "Inactive", }; default: return { - color: 'bg-blue-500', + color: "bg-blue-500", tooltip: status, }; } @@ -105,35 +114,28 @@ export default function AccountsOverview() { if (accountsLoading) { return ( -
+ -
+ ); } if (accountsError) { return ( -
-
-
- -

- Failed to load accounts -

-

- Unable to connect to the Leggen API. Please check your - configuration and ensure the API server is running. -

- -
-
-
+ + + Failed to load accounts + +

+ Unable to connect to the Leggen API. Please check your configuration + and ensure the API server is running. +

+ +
+
); } @@ -151,213 +153,229 @@ export default function AccountsOverview() {
{/* Summary Cards */}
-
-
-
-

Total Balance

-

- {formatCurrency(totalBalance)} -

+ + +
+
+

+ Total Balance +

+

+ {formatCurrency(totalBalance)} +

+
+
+ +
-
- -
-
-
+ + -
-
-
-

- Total Accounts -

-

- {totalAccounts} -

+ + +
+
+

+ Total Accounts +

+

+ {totalAccounts} +

+
+
+ +
-
- -
-
-
+ + -
-
-
-

- Connected Banks -

-

{uniqueBanks}

+ + +
+
+

+ Connected Banks +

+

+ {uniqueBanks} +

+
+
+ +
-
- -
-
-
+ +
{/* Accounts List */} -
-
-

Bank Accounts

-

- Manage your connected bank accounts -

-
+ + + Bank Accounts + Manage your connected bank accounts + {!accounts || accounts.length === 0 ? ( -
- -

+ + +

No accounts found

-

+

Connect your first bank account to get started with Leggen.

-

+ ) : ( -
- {accounts.map((account) => { - // Get balance from account's balances array or fallback to balances query - const accountBalance = account.balances?.[0]; - const fallbackBalance = balances?.find( - (b) => b.account_id === account.id, - ); - const balance = - accountBalance?.amount || fallbackBalance?.balance_amount || 0; - const currency = - accountBalance?.currency || - fallbackBalance?.currency || - account.currency || - "EUR"; - const isPositive = balance >= 0; + +
+ {accounts.map((account) => { + // Get balance from account's balances array or fallback to balances query + const accountBalance = account.balances?.[0]; + const fallbackBalance = balances?.find( + (b) => b.account_id === account.id, + ); + const balance = + accountBalance?.amount || + fallbackBalance?.balance_amount || + 0; + const currency = + accountBalance?.currency || + fallbackBalance?.currency || + account.currency || + "EUR"; + const isPositive = balance >= 0; - return ( -
- {/* Mobile layout - stack vertically */} -
-
-
- -
-
- {editingAccountId === account.id ? ( -
-
- setEditingName(e.target.value)} - className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder="Account name" - name="search" - autoComplete="off" - onKeyDown={(e) => { - if (e.key === "Enter") handleEditSave(); - if (e.key === "Escape") handleEditCancel(); - }} - autoFocus - /> - - -
-

- {account.institution_id} -

-
- ) : ( -
-
-

- {account.name || "Unnamed Account"} -

- -
-

- {account.institution_id} -

- {account.iban && ( -

- IBAN: {account.iban} -

- )} -
- )} -
-
- - {/* Balance and date section */} -
- {/* Mobile: date/status on left, balance on right */} - {/* Desktop: balance on top, date/status on bottom */} - - {/* Date and status indicator - left on mobile, bottom on desktop */} -
-
- {/* Tooltip */} -
- {getStatusIndicator(account.status).tooltip} -
-
+ return ( +
+ {/* Mobile layout - stack vertically */} +
+
+
+ +
+
+ {editingAccountId === account.id ? ( +
+
+ + setEditingName(e.target.value) + } + className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" + placeholder="Account name" + name="search" + autoComplete="off" + onKeyDown={(e) => { + if (e.key === "Enter") handleEditSave(); + if (e.key === "Escape") handleEditCancel(); + }} + autoFocus + /> + + +
+

+ {account.institution_id} +

+
+ ) : ( +
+
+

+ {account.name || "Unnamed Account"} +

+ +
+

+ {account.institution_id} +

+ {account.iban && ( +

+ IBAN: {account.iban} +

+ )} +
+ )}
-

- Updated{" "} - {formatDate(account.last_accessed || account.created)} -

- - {/* Balance - right on mobile, top on desktop */} -
- {isPositive ? ( - - ) : ( - - )} -

- {formatCurrency(balance, currency)} -

+ + {/* Balance and date section */} +
+ {/* Mobile: date/status on left, balance on right */} + {/* Desktop: balance on top, date/status on bottom */} + + {/* Date and status indicator - left on mobile, bottom on desktop */} +
+
+ {/* Tooltip */} +
+ {getStatusIndicator(account.status).tooltip} +
+
+
+

+ Updated{" "} + {formatDate( + account.last_accessed || account.created, + )} +

+
+ + {/* Balance - right on mobile, top on desktop */} +
+ {isPositive ? ( + + ) : ( + + )} +

+ {formatCurrency(balance, currency)} +

+
-
- ); - })} -
+ ); + })} +
+ )} -
+
); } diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx deleted file mode 100644 index adf6a77..0000000 --- a/frontend/src/components/Dashboard.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - CreditCard, - TrendingUp, - Activity, - Menu, - X, - Home, - List, - BarChart3, - Wifi, - WifiOff, - Bell, -} from "lucide-react"; -import { apiClient } from "../lib/api"; -import AccountsOverview from "./AccountsOverview"; -import TransactionsTable from "./TransactionsTable"; -import Notifications from "./Notifications"; -import ErrorBoundary from "./ErrorBoundary"; -import { cn } from "../lib/utils"; -import type { Account } from "../types/api"; - -type TabType = "overview" | "transactions" | "analytics" | "notifications"; - -export default function Dashboard() { - const [activeTab, setActiveTab] = useState("overview"); - const [sidebarOpen, setSidebarOpen] = useState(false); - - const { data: accounts } = useQuery({ - queryKey: ["accounts"], - queryFn: apiClient.getAccounts, - }); - - const { - data: healthStatus, - isLoading: healthLoading, - isError: healthError, - } = useQuery({ - queryKey: ["health"], - queryFn: async () => { - return await apiClient.getHealth(); - }, - refetchInterval: 30000, // Check every 30 seconds - retry: 3, - }); - - const navigation = [ - { name: "Overview", icon: Home, id: "overview" as TabType }, - { name: "Transactions", icon: List, id: "transactions" as TabType }, - { name: "Analytics", icon: BarChart3, id: "analytics" as TabType }, - { name: "Notifications", icon: Bell, id: "notifications" as TabType }, - ]; - - const totalBalance = - accounts?.reduce((sum, account) => { - // Get the first available balance from the balances array - const primaryBalance = account.balances?.[0]?.amount || 0; - return sum + primaryBalance; - }, 0) || 0; - - return ( -
- {/* Sidebar */} -
-
-
- -

Leggen

-
- -
- - - - {/* Account Summary in Sidebar */} -
-
-
- - Total Balance - - -
-

- {new Intl.NumberFormat("en-US", { - style: "currency", - currency: "EUR", - }).format(totalBalance)} -

-

- {accounts?.length || 0} accounts -

-
-
-
- - {/* Overlay for mobile */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Main content */} -
- {/* Header */} -
-
-
- -

- {navigation.find((item) => item.id === activeTab)?.name} -

-
-
-
- {healthLoading ? ( - <> - - Checking... - - ) : healthError || healthStatus?.status !== "healthy" ? ( - <> - - Disconnected - - ) : ( - <> - - Connected - - )} -
-
-
-
- - {/* Main content area */} -
- - {activeTab === "overview" && } - {activeTab === "transactions" && } - {activeTab === "analytics" && ( -
-

- Analytics -

-

- Analytics dashboard coming soon... -

-
- )} - {activeTab === "notifications" && } -
-
-
-
- ); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 88533c2..7025fa1 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router"; import { Menu, Activity, Wifi, WifiOff } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { apiClient } from "../lib/api"; +import { ThemeToggle } from "./ui/theme-toggle"; const navigation = [ { name: "Overview", to: "/" }, @@ -31,25 +32,27 @@ export default function Header({ setSidebarOpen }: HeaderProps) { }); return ( -
+
-

+

{currentPage}

-
+
{healthLoading ? ( <> - Checking... + + Checking... + ) : healthError || healthStatus?.status !== "healthy" ? ( <> @@ -59,10 +62,11 @@ export default function Header({ setSidebarOpen }: HeaderProps) { ) : ( <> - Connected + Connected )}
+
diff --git a/frontend/src/components/Notifications.tsx b/frontend/src/components/Notifications.tsx index 17cdeb0..356cc9b 100644 --- a/frontend/src/components/Notifications.tsx +++ b/frontend/src/components/Notifications.tsx @@ -13,6 +13,24 @@ import { } from "lucide-react"; import { apiClient } from "../lib/api"; import LoadingSpinner from "./LoadingSpinner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; import type { NotificationSettings, NotificationService } from "../types/api"; export default function Notifications() { @@ -63,38 +81,35 @@ export default function Notifications() { if (settingsLoading || servicesLoading) { return ( -
+ -
+ ); } if (settingsError || servicesError) { return ( -
-
-
- -

- Failed to load notifications -

-

- Unable to connect to the Leggen API. Please check your - configuration and ensure the API server is running. -

- -
-
-
+ + + Failed to load notifications + +

+ Unable to connect to the Leggen API. Please check your configuration + and ensure the API server is running. +

+ +
+
); } @@ -120,201 +135,202 @@ export default function Notifications() { return (
{/* Test Notification Section */} -
-
- -

- Test Notifications -

-
+ + + + + Test Notifications + + + +
+
+ + +
-
-
- - setTestMessage(e.target.value)} + placeholder="Test message..." + /> +
+
+ +
+
- -
- - setTestMessage(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Test message..." - /> -
-
- -
- -
-
+ + {/* Notification Services */} -
-
-
- -

- Notification Services -

-
-

- Manage your notification services -

-
+ + + + + Notification Services + + Manage your notification services + {!services || services.length === 0 ? ( -
- -

+ + +

No notification services configured

-

+

Configure notification services in your backend to receive alerts.

-

+ ) : ( -
- {services.map((service) => ( -
-
-
-
- {service.name.toLowerCase().includes("discord") ? ( - - ) : service.name.toLowerCase().includes("telegram") ? ( - - ) : ( - - )} -
-
-

- {service.name} -

-
- - {service.enabled ? ( - - ) : ( - - )} - {service.enabled ? "Enabled" : "Disabled"} - - - {service.configured ? "Configured" : "Not Configured"} - + +
+ {services.map((service) => ( +
+
+
+
+ {service.name.toLowerCase().includes("discord") ? ( + + ) : service.name.toLowerCase().includes("telegram") ? ( + + ) : ( + + )} +
+
+

+ {service.name} +

+
+ + {service.enabled ? ( + + ) : ( + + )} + {service.enabled ? "Enabled" : "Disabled"} + + + {service.configured + ? "Configured" + : "Not Configured"} + +
-
-
- +
-
- ))} -
+ ))} +
+ )} -
+ {/* Notification Settings */} -
-
- -

- Notification Settings -

-
- - {settings && ( -
-
-

- Filters -

-
-
-
- -

- {settings.filters.case_insensitive.length > 0 - ? settings.filters.case_insensitive.join(", ") - : "None"} -

-
-
- -

- {settings.filters.case_sensitive && - settings.filters.case_sensitive.length > 0 - ? settings.filters.case_sensitive.join(", ") - : "None"} -

+ + + + + Notification Settings + + + + {settings && ( +
+
+

+ Filters +

+
+
+
+ +

+ {settings.filters.case_insensitive.length > 0 + ? settings.filters.case_insensitive.join(", ") + : "None"} +

+
+
+ +

+ {settings.filters.case_sensitive && + settings.filters.case_sensitive.length > 0 + ? settings.filters.case_sensitive.join(", ") + : "None"} +

+
-
-
-

- Configure notification settings through your backend API to - customize filters and service configurations. -

+
+

+ Configure notification settings through your backend API to + customize filters and service configurations. +

+
-
- )} -
+ )} + +
); } diff --git a/frontend/src/components/RawTransactionModal.tsx b/frontend/src/components/RawTransactionModal.tsx index bf82d7b..b0d4167 100644 --- a/frontend/src/components/RawTransactionModal.tsx +++ b/frontend/src/components/RawTransactionModal.tsx @@ -1,5 +1,6 @@ import { X, Copy, Check } from "lucide-react"; import { useState } from "react"; +import { Button } from "./ui/button"; import type { RawTransactionData } from "../types/api"; interface RawTransactionModalProps { @@ -38,26 +39,27 @@ export default function RawTransactionModal({
{/* Background overlay */}
{/* Modal panel */} -
-
+
+
-

+

Raw Transaction Data

- - + +
-

+

Transaction ID:{" "} - + {transactionId}

{rawTransaction ? ( -
-
+              
+
                   {JSON.stringify(rawTransaction, null, 2)}
                 
) : ( -
-

+

+

Raw transaction data is not available for this transaction.

-

+

Try refreshing the page or check if the transaction was fetched with summary_only=false.

@@ -104,14 +103,14 @@ export default function RawTransactionModal({ )}
-
- +
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1147fa4..9181721 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -43,22 +43,22 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { return (
-
+
setSidebarOpen(false)} className="flex items-center space-x-2 hover:opacity-80 transition-opacity" > - -

Leggen

+ +

Leggen

@@ -71,11 +71,12 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { key={item.to} to={item.to} onClick={() => setSidebarOpen(false)} - className={`flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors ${ + className={cn( + "flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors", location.pathname === item.to - ? "bg-blue-100 text-blue-700" - : "text-gray-700 hover:text-gray-900 hover:bg-gray-100" - }`} + ? "bg-primary text-primary-foreground" + : "text-card-foreground hover:text-card-foreground hover:bg-accent", + )} > {item.name} @@ -85,18 +86,18 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { {/* Account Summary in Sidebar */} -
-
+
+
- + Total Balance
-

+

{formatCurrency(totalBalance)}

-

+

{accounts?.length || 0} accounts

diff --git a/frontend/src/components/TransactionSkeleton.tsx b/frontend/src/components/TransactionSkeleton.tsx index d7c083c..4dbd699 100644 --- a/frontend/src/components/TransactionSkeleton.tsx +++ b/frontend/src/components/TransactionSkeleton.tsx @@ -5,7 +5,7 @@ interface TransactionSkeletonProps { export default function TransactionSkeleton({ rows = 5, - view = "table" + view = "table", }: TransactionSkeletonProps) { const skeletonRows = Array.from({ length: rows }, (_, index) => index); diff --git a/frontend/src/components/TransactionsTable.tsx b/frontend/src/components/TransactionsTable.tsx index 4d1a83f..fb1242d 100644 --- a/frontend/src/components/TransactionsTable.tsx +++ b/frontend/src/components/TransactionsTable.tsx @@ -27,6 +27,10 @@ import TransactionSkeleton from "./TransactionSkeleton"; import FiltersSkeleton from "./FiltersSkeleton"; import RawTransactionModal from "./RawTransactionModal"; import { FilterBar, type FilterState } from "./filters"; +import { DataTablePagination } from "./ui/data-table-pagination"; +import { Card, CardContent } from "./ui/card"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; import type { Account, Transaction, ApiResponse, Balance } from "../types/api"; export default function TransactionsTable() { @@ -50,7 +54,9 @@ export default function TransactionsTable() { const [perPage, setPerPage] = useState(50); // Debounced search state - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState( + filterState.searchTerm, + ); // Table state (remove pagination from table) const [sorting, setSorting] = useState([]); @@ -128,8 +134,12 @@ export default function TransactionsTable() { perPage: perPage, search: debouncedSearchTerm || undefined, summaryOnly: false, - minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined, - maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined, + minAmount: filterState.minAmount + ? parseFloat(filterState.minAmount) + : undefined, + maxAmount: filterState.maxAmount + ? parseFloat(filterState.maxAmount) + : undefined, }), }); @@ -149,7 +159,13 @@ export default function TransactionsTable() { // Reset pagination when filters change useEffect(() => { setCurrentPage(1); - }, [filterState.selectedAccount, filterState.startDate, filterState.endDate, filterState.minAmount, filterState.maxAmount]); + }, [ + filterState.selectedAccount, + filterState.startDate, + filterState.endDate, + filterState.minAmount, + filterState.maxAmount, + ]); const handleViewRaw = (transaction: Transaction) => { setSelectedTransaction(transaction); @@ -177,15 +193,15 @@ export default function TransactionsTable() { const accountBalanceMap = new Map(); // Create a map of account current balances - balances.forEach(balance => { - if (balance.balance_type === 'expected') { + balances.forEach((balance) => { + if (balance.balance_type === "expected") { accountBalanceMap.set(balance.account_id, balance.balance_amount); } }); // Group transactions by account const transactionsByAccount = new Map(); - transactions.forEach(txn => { + transactions.forEach((txn) => { if (!transactionsByAccount.has(txn.account_id)) { transactionsByAccount.set(txn.account_id, []); } @@ -198,13 +214,16 @@ export default function TransactionsTable() { let runningBalance = currentBalance; // Sort transactions by date (newest first) to work backwards - const sortedTransactions = [...accountTransactions].sort((a, b) => - new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime() + const sortedTransactions = [...accountTransactions].sort( + (a, b) => + new Date(b.transaction_date).getTime() - + new Date(a.transaction_date).getTime(), ); // Calculate running balance by working backwards from current balance sortedTransactions.forEach((txn) => { - runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance; + runningBalances[`${txn.account_id}-${txn.transaction_id}`] = + runningBalance; runningBalance -= txn.transaction_value; }); }); @@ -240,10 +259,10 @@ export default function TransactionsTable() { )}
-

+

{transaction.description}

-
+
{account && (

{account.name || "Unnamed Account"} •{" "} @@ -289,38 +308,42 @@ export default function TransactionsTable() { }, sortingFn: "basic", }, - ...(showRunningBalance ? [{ - id: "running_balance", - header: "Running Balance", - cell: ({ row }: { row: { original: Transaction } }) => { - const transaction = row.original; - const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`; - const balance = runningBalances[balanceKey]; + ...(showRunningBalance + ? [ + { + id: "running_balance", + header: "Running Balance", + cell: ({ row }: { row: { original: Transaction } }) => { + const transaction = row.original; + const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`; + const balance = runningBalances[balanceKey]; - if (balance === undefined) return null; + if (balance === undefined) return null; - return ( -

-

- {formatCurrency(balance, transaction.transaction_currency)} -

-
- ); - }, - }] : []), + return ( +
+

+ {formatCurrency(balance, transaction.transaction_currency)} +

+
+ ); + }, + }, + ] + : []), { accessorKey: "transaction_date", header: "Date", cell: ({ row }) => { const transaction = row.original; return ( -
+
{transaction.transaction_date ? formatDate(transaction.transaction_date) : "No date"} {transaction.booking_date && transaction.booking_date !== transaction.transaction_date && ( -

+

Booked: {formatDate(transaction.booking_date)}

)} @@ -337,7 +360,7 @@ export default function TransactionsTable() { return ( -
-
-
+ + + Failed to load transactions + +

Unable to fetch transactions from the Leggen API.

+ +
+
); } @@ -428,13 +447,15 @@ export default function TransactionsTable() { accounts={accounts} isSearchLoading={isSearchLoading} showRunningBalance={showRunningBalance} - onToggleRunningBalance={() => setShowRunningBalance(!showRunningBalance)} + onToggleRunningBalance={() => + setShowRunningBalance(!showRunningBalance) + } /> {/* Results Summary */} -
-
-

+ + +

Showing {transactions.length} transaction {transactions.length !== 1 ? "s" : ""} ( {pagination ? ( @@ -452,26 +473,30 @@ export default function TransactionsTable() { ) {filterState.selectedAccount && accounts && ( - for {accounts.find((acc) => acc.id === filterState.selectedAccount)?.name} + for{" "} + { + accounts.find((acc) => acc.id === filterState.selectedAccount) + ?.name + } )}

-
-
+ + {/* Responsive Table/Cards */} -
+ {/* Desktop Table View (hidden on mobile) */}
- - +
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} - + {table.getRowModel().rows.length === 0 ? ( ) : ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( - - ) + ); }, ...components, }} {...props} /> - ) + ); } function CalendarDayButton({ @@ -176,12 +176,12 @@ function CalendarDayButton({ modifiers, ...props }: React.ComponentProps) { - const defaultClassNames = getDefaultClassNames() + const defaultClassNames = getDefaultClassNames(); - const ref = React.useRef(null) + const ref = React.useRef(null); React.useEffect(() => { - if (modifiers.focused) ref.current?.focus() - }, [modifiers.focused]) + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); return ( + + + + + +
+ Showing {(currentPage - 1) * pageSize + 1} to{" "} + {Math.min(currentPage * pageSize, total)} of {total} entries +
+ + + {/* Mobile view */} +
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+ + ); +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 9dbeaa0..d72e592 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,16 +1,16 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, @@ -20,12 +20,12 @@ const DialogOverlay = React.forwardRef< ref={ref} className={cn( "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className + className, )} {...props} /> -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, @@ -37,7 +37,7 @@ const DialogContent = React.forwardRef< ref={ref} className={cn( "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className + className, )} {...props} > @@ -48,8 +48,8 @@ const DialogContent = React.forwardRef< -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, @@ -58,12 +58,12 @@ const DialogHeader = ({
-) -DialogHeader.displayName = "DialogHeader" +); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, @@ -72,12 +72,12 @@ const DialogFooter = ({
-) -DialogFooter.displayName = "DialogFooter" +); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -87,12 +87,12 @@ const DialogTitle = React.forwardRef< ref={ref} className={cn( "text-lg font-semibold leading-none tracking-tight", - className + className, )} {...props} /> -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -103,8 +103,8 @@ const DialogDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, @@ -117,4 +117,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -} +}; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 69b64fb..7db5241 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { @@ -9,14 +9,14 @@ const Input = React.forwardRef>( type={type} className={cn( "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - className + className, )} ref={ref} {...props} /> - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..a4e9b69 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Label = React.forwardRef< + HTMLLabelElement, + React.LabelHTMLAttributes +>(({ className, ...props }, ref) => ( +
@@ -488,15 +513,15 @@ export default function TransactionsTable() {
@@ -507,20 +532,20 @@ export default function TransactionsTable() {
-
+
-

+

No transactions found

-

+

{hasActiveFilters ? "Try adjusting your filters to see more results." : "No transactions are available for the selected criteria."} @@ -529,9 +554,12 @@ export default function TransactionsTable() {

+ {flexRender( cell.column.columnDef.cell, cell.getContext(), @@ -550,20 +578,20 @@ export default function TransactionsTable() {
{table.getRowModel().rows.length === 0 ? (
-
+
-

+

No transactions found

-

+

{hasActiveFilters ? "Try adjusting your filters to see more results." : "No transactions are available for the selected criteria."}

) : ( -
+
{table.getRowModel().rows.map((row) => { const transaction = row.original; const account = accounts?.find( @@ -574,7 +602,7 @@ export default function TransactionsTable() { return (
@@ -591,33 +619,39 @@ export default function TransactionsTable() { )}
-

+

{transaction.description}

-
+
{account && (

{account.name || "Unnamed Account"} •{" "} {account.institution_id}

)} - {(transaction.creditor_name || transaction.debtor_name) && ( + {(transaction.creditor_name || + transaction.debtor_name) && (

{isPositive ? "From: " : "To: "} - {transaction.creditor_name || transaction.debtor_name} + {transaction.creditor_name || + transaction.debtor_name}

)} {transaction.reference && ( -

Ref: {transaction.reference}

+

+ Ref: {transaction.reference} +

)} -

+

{transaction.transaction_date ? formatDate(transaction.transaction_date) : "No date"} {transaction.booking_date && - transaction.booking_date !== transaction.transaction_date && ( + transaction.booking_date !== + transaction.transaction_date && ( - (Booked: {formatDate(transaction.booking_date)}) + (Booked:{" "} + {formatDate(transaction.booking_date)}) )}

@@ -638,16 +672,19 @@ export default function TransactionsTable() { )}

{showRunningBalance && ( -

- Balance: {formatCurrency( - runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0, +

+ Balance:{" "} + {formatCurrency( + runningBalances[ + `${transaction.account_id}-${transaction.transaction_id}` + ] || 0, transaction.transaction_currency, )}

)} - -
-
- - -
-
- - {/* Mobile pagination info */} -
-

- Page {pagination.page} of{" "} - {pagination.total_pages} -
- - Showing {(pagination.page - 1) * pagination.per_page + 1}- - {Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total} - -

-
- - {/* Desktop pagination */} -
-
-

- Showing{" "} - - {(pagination.page - 1) * pagination.per_page + 1} - {" "} - to{" "} - - {Math.min( - pagination.page * pagination.per_page, - pagination.total, - )} - {" "} - of {pagination.total}{" "} - results -

-
-
-
- - -
-
- - - - Page {pagination.page}{" "} - of{" "} - - {pagination.total_pages} - - - - -
-
-
-
+ )} -
+ {/* Raw Transaction Modal */} ; + label?: string; +} + +export default function BalanceChart({ + data, + accounts, + className, +}: BalanceChartProps) { // Create a lookup map for account info - const accountMap = accounts.reduce((map, account) => { - map[account.id] = account; - return map; - }, {} as Record); + const accountMap = accounts.reduce( + (map, account) => { + map[account.id] = account; + return map; + }, + {} as Record, + ); // Helper function to get bank name from institution_id const getBankName = (institutionId: string): string => { const bankMapping: Record = { - 'REVOLUT_REVOLT21': 'Revolut', - 'NUBANK_NUPBBR25': 'Nu Pagamentos', - 'BANCOBPI_BBPIPTPL': 'Banco BPI', + REVOLUT_REVOLT21: "Revolut", + NUBANK_NUPBBR25: "Nu Pagamentos", + BANCOBPI_BBPIPTPL: "Banco BPI", // Add more mappings as needed }; - return bankMapping[institutionId] || institutionId.split('_')[0]; + return bankMapping[institutionId] || institutionId.split("_")[0]; }; // Helper function to create display name for account @@ -50,20 +67,24 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart const account = accountMap[accountId]; if (account) { const bankName = getBankName(account.institution_id); - const accountName = account.name || `Account ${accountId.split('-')[1]}`; + const accountName = account.name || `Account ${accountId.split("-")[1]}`; return `${bankName} - ${accountName}`; } - return `Account ${accountId.split('-')[1]}`; + return `Account ${accountId.split("-")[1]}`; }; // Process balance data for the chart const chartData = data .filter((balance) => balance.balance_type === "closingBooked") .map((balance) => ({ - date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format + date: new Date(balance.reference_date).toLocaleDateString("en-GB"), // DD/MM/YYYY format balance: balance.balance_amount, account_id: balance.account_id, })) - .sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()); + .sort( + (a, b) => + new Date(a.date.split("/").reverse().join("/")).getTime() - + new Date(b.date.split("/").reverse().join("/")).getTime(), + ); // Group by account and aggregate const accountBalances: { [key: string]: ChartDataPoint[] } = {}; @@ -86,18 +107,37 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart }); const finalData = Object.values(aggregatedData).sort( - (a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime() + (a, b) => + new Date(a.date.split("/").reverse().join("/")).getTime() - + new Date(b.date.split("/").reverse().join("/")).getTime(), ); const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; + const CustomTooltip = ({ active, payload, label }: TooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

Date: {label}

+ {payload.map((entry, index) => ( +

+ {getAccountDisplayName(entry.name)}: € + {entry.value.toLocaleString()} +

+ ))} +
+ ); + } + return null; + }; + if (finalData.length === 0) { return (
-

+

Balance Progress

-
+
No balance data available
@@ -106,7 +146,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart return (
-

+

Balance Progress Over Time

@@ -118,9 +158,9 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart tick={{ fontSize: 12 }} tickFormatter={(value) => { // Convert DD/MM/YYYY back to a proper date for formatting - const [day, month, year] = value.split('/'); + const [day, month, year] = value.split("/"); const date = new Date(year, month - 1, day); - return date.toLocaleDateString('en-GB', { + return date.toLocaleDateString("en-GB", { month: "short", day: "numeric", }); @@ -130,13 +170,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart tick={{ fontSize: 12 }} tickFormatter={(value) => `€${value.toLocaleString()}`} /> - [ - `€${value.toLocaleString()}`, - getAccountDisplayName(name), - ]} - labelFormatter={(label) => `Date: ${label}`} - /> + } /> {Object.keys(accountBalances).map((accountId, index) => (
); -} \ No newline at end of file +} diff --git a/frontend/src/components/analytics/MonthlyTrends.tsx b/frontend/src/components/analytics/MonthlyTrends.tsx index 49fad3e..1eaa10c 100644 --- a/frontend/src/components/analytics/MonthlyTrends.tsx +++ b/frontend/src/components/analytics/MonthlyTrends.tsx @@ -15,7 +15,6 @@ interface MonthlyTrendsProps { days?: number; } - interface TooltipProps { active?: boolean; payload?: Array<{ @@ -26,7 +25,10 @@ interface TooltipProps { label?: string; } -export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsProps) { +export default function MonthlyTrends({ + className, + days = 365, +}: MonthlyTrendsProps) { // Get pre-calculated monthly stats from the new endpoint const { data: monthlyData, isLoading } = useQuery({ queryKey: ["monthly-stats", days], @@ -49,11 +51,11 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr if (isLoading) { return (
-

+

Monthly Spending Trends

-
+
); @@ -62,10 +64,10 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr if (displayData.length === 0) { return (
-

+

Monthly Spending Trends

-
+
No transaction data available
@@ -75,8 +77,8 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr const CustomTooltip = ({ active, payload, label }: TooltipProps) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((entry, index) => (

{entry.name}: €{Math.abs(entry.value).toLocaleString()} @@ -98,12 +100,15 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr return (

-

+

{getTitle(days)}

- +
-
+
Income diff --git a/frontend/src/components/analytics/StatCard.tsx b/frontend/src/components/analytics/StatCard.tsx index a000f62..cd9da41 100644 --- a/frontend/src/components/analytics/StatCard.tsx +++ b/frontend/src/components/analytics/StatCard.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from "lucide-react"; -import clsx from "clsx"; +import { Card, CardContent } from "../ui/card"; +import { cn } from "../../lib/utils"; interface StatCardProps { title: string; @@ -22,43 +23,44 @@ export default function StatCard({ className, }: StatCardProps) { return ( -
-
-
- -
-
-
-
- {title} -
-
-
- {value} -
- {trend && ( -
- {trend.isPositive ? "+" : ""} - {trend.value}% + + +
+
+ +
+
+
+
+ {title} +
+
+
+ {value}
+ {trend && ( +
+ {trend.isPositive ? "+" : ""} + {trend.value}% +
+ )} +
+ {subtitle && ( +
+ {subtitle} +
)} -
- {subtitle && ( -
{subtitle}
- )} -
+ +
-
-
+ + ); -} \ No newline at end of file +} diff --git a/frontend/src/components/analytics/TimePeriodFilter.tsx b/frontend/src/components/analytics/TimePeriodFilter.tsx index e9ecdec..4e0f7c8 100644 --- a/frontend/src/components/analytics/TimePeriodFilter.tsx +++ b/frontend/src/components/analytics/TimePeriodFilter.tsx @@ -1,4 +1,5 @@ import { Calendar } from "lucide-react"; +import { Button } from "../ui/button"; import type { TimePeriod } from "../../lib/timePeriods"; import { TIME_PERIODS } from "../../lib/timePeriods"; @@ -15,25 +16,24 @@ export default function TimePeriodFilter({ }: TimePeriodFilterProps) { return (
-
+
Time Period:
{TIME_PERIODS.map((period) => ( - + ))}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/analytics/TransactionDistribution.tsx b/frontend/src/components/analytics/TransactionDistribution.tsx index e440e09..9381e76 100644 --- a/frontend/src/components/analytics/TransactionDistribution.tsx +++ b/frontend/src/components/analytics/TransactionDistribution.tsx @@ -33,27 +33,27 @@ export default function TransactionDistribution({ // Helper function to get bank name from institution_id const getBankName = (institutionId: string): string => { const bankMapping: Record = { - 'REVOLUT_REVOLT21': 'Revolut', - 'NUBANK_NUPBBR25': 'Nu Pagamentos', - 'BANCOBPI_BBPIPTPL': 'Banco BPI', + REVOLUT_REVOLT21: "Revolut", + NUBANK_NUPBBR25: "Nu Pagamentos", + BANCOBPI_BBPIPTPL: "Banco BPI", // TODO: Add more bank mappings as needed }; - return bankMapping[institutionId] || institutionId.split('_')[0]; + return bankMapping[institutionId] || institutionId.split("_")[0]; }; // Helper function to create display name for account const getAccountDisplayName = (account: Account): string => { const bankName = getBankName(account.institution_id); - const accountName = account.name || `Account ${account.id.split('-')[1]}`; + const accountName = account.name || `Account ${account.id.split("-")[1]}`; return `${bankName} - ${accountName}`; }; // Create pie chart data from account balances const pieData: PieDataPoint[] = accounts.map((account, index) => { const primaryBalance = account.balances?.[0]?.amount || 0; - + const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"]; - + return { name: getAccountDisplayName(account), value: primaryBalance, @@ -66,10 +66,10 @@ export default function TransactionDistribution({ if (pieData.length === 0 || totalBalance === 0) { return (
-

+

Account Distribution

-
+
No account data available
@@ -81,12 +81,12 @@ export default function TransactionDistribution({ const data = payload[0].payload; const percentage = ((data.value / totalBalance) * 100).toFixed(1); return ( -
-

{data.name}

-

+

+

{data.name}

+

Balance: €{data.value.toLocaleString()}

-

{percentage}% of total

+

{percentage}% of total

); } @@ -95,7 +95,7 @@ export default function TransactionDistribution({ return (
-

+

Account Balance Distribution

@@ -125,18 +125,23 @@ export default function TransactionDistribution({
{pieData.map((item, index) => ( -
+
- {item.name} + {item.name}
- €{item.value.toLocaleString()} + + €{item.value.toLocaleString()} +
))}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/filters/AccountCombobox.tsx b/frontend/src/components/filters/AccountCombobox.tsx index 6fa35a0..c3b7c1e 100644 --- a/frontend/src/components/filters/AccountCombobox.tsx +++ b/frontend/src/components/filters/AccountCombobox.tsx @@ -11,7 +11,11 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import type { Account } from "../../types/api"; export interface AccountComboboxProps { @@ -29,7 +33,9 @@ export function AccountCombobox({ }: AccountComboboxProps) { const [open, setOpen] = useState(false); - const selectedAccountData = accounts.find((account) => account.id === selectedAccount); + const selectedAccountData = accounts.find( + (account) => account.id === selectedAccount, + ); const formatAccountName = (account: Account) => { const displayName = account.name || "Unnamed Account"; @@ -72,7 +78,7 @@ export function AccountCombobox({ @@ -94,7 +100,7 @@ export function AccountCombobox({ "mr-2 h-4 w-4", selectedAccount === account.id ? "opacity-100" - : "opacity-0" + : "opacity-0", )} />
diff --git a/frontend/src/components/filters/ActiveFilterChips.tsx b/frontend/src/components/filters/ActiveFilterChips.tsx index d91bf84..697ea78 100644 --- a/frontend/src/components/filters/ActiveFilterChips.tsx +++ b/frontend/src/components/filters/ActiveFilterChips.tsx @@ -33,7 +33,9 @@ export function ActiveFilterChips({ // Account chip if (filterState.selectedAccount) { - const account = accounts.find((acc) => acc.id === filterState.selectedAccount); + const account = accounts.find( + (acc) => acc.id === filterState.selectedAccount, + ); const accountName = account ? `${account.name || "Unnamed Account"} (${account.institution_id})` : "Unknown Account"; @@ -69,8 +71,12 @@ export function ActiveFilterChips({ // Amount range chips if (filterState.minAmount || filterState.maxAmount) { let amountLabel = "Amount: "; - const minAmount = filterState.minAmount ? parseFloat(filterState.minAmount) : null; - const maxAmount = filterState.maxAmount ? parseFloat(filterState.maxAmount) : null; + const minAmount = filterState.minAmount + ? parseFloat(filterState.minAmount) + : null; + const maxAmount = filterState.maxAmount + ? parseFloat(filterState.maxAmount) + : null; if (minAmount && maxAmount) { amountLabel += `€${minAmount} - €${maxAmount}`; diff --git a/frontend/src/components/filters/AdvancedFiltersPopover.tsx b/frontend/src/components/filters/AdvancedFiltersPopover.tsx index 4a06713..f2b327e 100644 --- a/frontend/src/components/filters/AdvancedFiltersPopover.tsx +++ b/frontend/src/components/filters/AdvancedFiltersPopover.tsx @@ -2,7 +2,11 @@ import { useState } from "react"; import { MoreHorizontal, Euro } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; export interface AdvancedFiltersPopoverProps { minAmount: string; @@ -94,7 +98,8 @@ export function AdvancedFiltersPopover({ {/* Future: Add transaction status filter */}
- More filters coming soon: transaction status, categories, and more. + More filters coming soon: transaction status, categories, and + more.
diff --git a/frontend/src/components/filters/DateRangePicker.tsx b/frontend/src/components/filters/DateRangePicker.tsx index bf10da7..eb3cf27 100644 --- a/frontend/src/components/filters/DateRangePicker.tsx +++ b/frontend/src/components/filters/DateRangePicker.tsx @@ -6,7 +6,11 @@ import type { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; export interface DateRangePickerProps { startDate: string; @@ -39,7 +43,9 @@ const datePresets: DatePreset[] = [ const now = new Date(); const dayOfWeek = now.getDay(); const startOfWeek = new Date(now); - startOfWeek.setDate(now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1)); // Monday as start + startOfWeek.setDate( + now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1), + ); // Monday as start const endOfWeek = new Date(startOfWeek); endOfWeek.setDate(startOfWeek.getDate() + 6); @@ -111,12 +117,12 @@ export function DateRangePicker({ if (range?.from && range?.to) { onDateRangeChange( range.from.toISOString().split("T")[0], - range.to.toISOString().split("T")[0] + range.to.toISOString().split("T")[0], ); } else if (range?.from && !range?.to) { onDateRangeChange( range.from.toISOString().split("T")[0], - range.from.toISOString().split("T")[0] + range.from.toISOString().split("T")[0], ); } }; @@ -161,7 +167,7 @@ export function DateRangePicker({ variant="outline" className={cn( "justify-between text-left font-normal", - !dateRange && "text-muted-foreground" + !dateRange && "text-muted-foreground", )} >
diff --git a/frontend/src/components/filters/FilterBar.tsx b/frontend/src/components/filters/FilterBar.tsx index 2560541..368c85c 100644 --- a/frontend/src/components/filters/FilterBar.tsx +++ b/frontend/src/components/filters/FilterBar.tsx @@ -38,7 +38,6 @@ export function FilterBar({ onToggleRunningBalance, className, }: FilterBarProps) { - const hasActiveFilters = filterState.searchTerm || filterState.selectedAccount || @@ -53,11 +52,13 @@ export function FilterBar({ }; return ( -
+
{/* Main Filter Bar */}
-

Transactions

+

+ Transactions +

+ +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/frontend/src/components/ui/theme-toggle.tsx b/frontend/src/components/ui/theme-toggle.tsx new file mode 100644 index 0000000..cd4f6f1 --- /dev/null +++ b/frontend/src/components/ui/theme-toggle.tsx @@ -0,0 +1,52 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { Button } from "./button"; +import { useTheme } from "../../contexts/ThemeContext"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const cycleTheme = () => { + if (theme === "light") { + setTheme("dark"); + } else if (theme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + }; + + const getIcon = () => { + switch (theme) { + case "light": + return ; + case "dark": + return ; + case "system": + return ; + } + }; + + const getLabel = () => { + switch (theme) { + case "light": + return "Switch to dark mode"; + case "dark": + return "Switch to system mode"; + case "system": + return "Switch to light mode"; + } + }; + + return ( + + ); +} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..34414e0 --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "light" | "dark" | "system"; + +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; + actualTheme: "light" | "dark"; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem("theme") as Theme; + return stored || "system"; + }); + + const [actualTheme, setActualTheme] = useState<"light" | "dark">("light"); + + useEffect(() => { + const root = window.document.documentElement; + + const updateActualTheme = () => { + let resolvedTheme: "light" | "dark"; + + if (theme === "system") { + resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + } else { + resolvedTheme = theme; + } + + setActualTheme(resolvedTheme); + + // Remove previous theme classes + root.classList.remove("light", "dark"); + + // Add resolved theme class + root.classList.add(resolvedTheme); + }; + + updateActualTheme(); + + // Listen for system theme changes + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + if (theme === "system") { + updateActualTheme(); + } + }; + + mediaQuery.addEventListener("change", handleChange); + + // Store theme preference + localStorage.setItem("theme", theme); + + return () => mediaQuery.removeEventListener("change", handleChange); + }, [theme]); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 0a1fb80..3e69677 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -28,7 +28,7 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; - --radius: 0.5rem + --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; @@ -54,7 +54,7 @@ --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; - --chart-5: 340 75% 55% + --chart-5: 340 75% 55%; } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 96b2d54..7b44510 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -56,13 +56,16 @@ export const apiClient = { }, // Get historical balances for balance progression chart - getHistoricalBalances: async (days?: number, accountId?: string): Promise => { + getHistoricalBalances: async ( + days?: number, + accountId?: string, + ): Promise => { const queryParams = new URLSearchParams(); if (days) queryParams.append("days", days.toString()); if (accountId) queryParams.append("account_id", accountId); const response = await api.get>( - `/balances/history?${queryParams.toString()}` + `/balances/history?${queryParams.toString()}`, ); return response.data.data; }, @@ -171,40 +174,48 @@ export const apiClient = { if (days) queryParams.append("days", days.toString()); const response = await api.get>( - `/transactions/stats?${queryParams.toString()}` + `/transactions/stats?${queryParams.toString()}`, ); return response.data.data; }, // Get all transactions for analytics (no pagination) - getTransactionsForAnalytics: async (days?: number): Promise => { + getTransactionsForAnalytics: async ( + days?: number, + ): Promise => { const queryParams = new URLSearchParams(); if (days) queryParams.append("days", days.toString()); const response = await api.get>( - `/transactions/analytics?${queryParams.toString()}` + `/transactions/analytics?${queryParams.toString()}`, ); return response.data.data; }, // Get monthly transaction statistics (pre-calculated) - getMonthlyTransactionStats: async (days?: number): Promise> => { - const queryParams = new URLSearchParams(); - if (days) queryParams.append("days", days.toString()); - - const response = await api.get>>( - `/transactions/monthly-stats?${queryParams.toString()}` - ); + }> + > => { + const queryParams = new URLSearchParams(); + if (days) queryParams.append("days", days.toString()); + + const response = await api.get< + ApiResponse< + Array<{ + month: string; + income: number; + expenses: number; + net: number; + }> + > + >(`/transactions/monthly-stats?${queryParams.toString()}`); return response.data.data; }, }; diff --git a/frontend/src/lib/timePeriods.ts b/frontend/src/lib/timePeriods.ts index d694a4e..d1b7ce0 100644 --- a/frontend/src/lib/timePeriods.ts +++ b/frontend/src/lib/timePeriods.ts @@ -16,4 +16,4 @@ export const TIME_PERIODS: TimePeriod[] = [ { label: "Last 6 months", days: 180, value: "6m" }, { label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" }, { label: "Last 365 days", days: 365, value: "365d" }, -]; \ No newline at end of file +]; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 7077270..9b46225 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,11 +1,14 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } -export function formatCurrency(amount: number, currency: string = "EUR"): string { +export function formatCurrency( + amount: number, + currency: string = "EUR", +): string { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a5addbe..a0de3a6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "./contexts/ThemeContext"; import "./index.css"; import { routeTree } from "./routeTree.gen"; @@ -19,7 +20,9 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 0a8137b..044adc1 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -7,13 +7,13 @@ function RootLayout() { const [sidebarOpen, setSidebarOpen] = useState(false); return ( -
+
{/* Mobile overlay */} {sidebarOpen && (
setSidebarOpen(false)} /> )} diff --git a/frontend/src/routes/analytics.tsx b/frontend/src/routes/analytics.tsx index 11b7ef1..b7216b9 100644 --- a/frontend/src/routes/analytics.tsx +++ b/frontend/src/routes/analytics.tsx @@ -14,13 +14,14 @@ import BalanceChart from "../components/analytics/BalanceChart"; import TransactionDistribution from "../components/analytics/TransactionDistribution"; import MonthlyTrends from "../components/analytics/MonthlyTrends"; import TimePeriodFilter from "../components/analytics/TimePeriodFilter"; +import { Card, CardContent } from "../components/ui/card"; import type { TimePeriod } from "../lib/timePeriods"; import { TIME_PERIODS } from "../lib/timePeriods"; function AnalyticsDashboard() { // Default to Last 365 days const [selectedPeriod, setSelectedPeriod] = useState( - TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3] + TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3], ); // Fetch analytics data @@ -45,15 +46,15 @@ function AnalyticsDashboard() { return (
-
+
{[...Array(3)].map((_, i) => ( -
+
))}
-
-
+
+
@@ -63,11 +64,14 @@ function AnalyticsDashboard() { return (
{/* Time Period Filter */} - + + + + + {/* Stats Cards */}
@@ -101,7 +105,9 @@ function AnalyticsDashboard() { subtitle="Income minus expenses" icon={CreditCard} className={ - (stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200" + (stats?.net_change || 0) >= 0 + ? "border-green-200" + : "border-red-200" } /> -
- -
-
- -
+ + + + + + + + + +
{/* Monthly Trends */} -
- -
+ + + + +
); } diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 47b93b4..f0c43d0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,57 +1,57 @@ /** @type {import('tailwindcss').Config} */ export default { - darkMode: ["class"], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - } - } + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + }, + }, }, plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")], };