feat(frontend): Complete shadcn/ui migration with dark mode support and analytics updates.

- 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 <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-15 01:30:34 +01:00
parent eb27f19196
commit 66db34c712
44 changed files with 1790 additions and 1295 deletions

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<div className="min-h-screen bg-gray-50">
<Dashboard />
</div>
</QueryClientProvider>
);
}
export default App;

View File

@@ -13,6 +13,15 @@ import {
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils"; 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 LoadingSpinner from "./LoadingSpinner";
import type { Account, Balance } from "../types/api"; import type { Account, Balance } from "../types/api";
@@ -21,30 +30,30 @@ const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase(); const statusLower = status.toLowerCase();
switch (statusLower) { switch (statusLower) {
case 'ready': case "ready":
return { return {
color: 'bg-green-500', color: "bg-green-500",
tooltip: 'Ready', tooltip: "Ready",
}; };
case 'pending': case "pending":
return { return {
color: 'bg-yellow-500', color: "bg-yellow-500",
tooltip: 'Pending', tooltip: "Pending",
}; };
case 'error': case "error":
case 'failed': case "failed":
return { return {
color: 'bg-red-500', color: "bg-red-500",
tooltip: 'Error', tooltip: "Error",
}; };
case 'inactive': case "inactive":
return { return {
color: 'bg-gray-500', color: "bg-gray-500",
tooltip: 'Inactive', tooltip: "Inactive",
}; };
default: default:
return { return {
color: 'bg-blue-500', color: "bg-blue-500",
tooltip: status, tooltip: status,
}; };
} }
@@ -105,35 +114,28 @@ export default function AccountsOverview() {
if (accountsLoading) { if (accountsLoading) {
return ( return (
<div className="bg-white rounded-lg shadow"> <Card>
<LoadingSpinner message="Loading accounts..." /> <LoadingSpinner message="Loading accounts..." />
</div> </Card>
); );
} }
if (accountsError) { if (accountsError) {
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <Alert variant="destructive">
<div className="flex items-center justify-center text-center"> <AlertCircle className="h-4 w-4" />
<div> <AlertTitle>Failed to load accounts</AlertTitle>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" /> <AlertDescription className="space-y-3">
<h3 className="text-lg font-medium text-gray-900 mb-2"> <p>
Failed to load accounts Unable to connect to the Leggen API. Please check your configuration
</h3> and ensure the API server is running.
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p> </p>
<button <Button onClick={() => refetchAccounts()} variant="outline" size="sm">
onClick={() => refetchAccounts()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Retry Retry
</button> </Button>
</div> </AlertDescription>
</div> </Alert>
</div>
); );
} }
@@ -151,72 +153,81 @@ export default function AccountsOverview() {
<div className="space-y-6"> <div className="space-y-6">
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6"> <Card>
<CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600">Total Balance</p> <p className="text-sm font-medium text-muted-foreground">
<p className="text-2xl font-bold text-gray-900"> Total Balance
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
</p> </p>
</div> </div>
<div className="p-3 bg-green-100 rounded-full"> <div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
<TrendingUp className="h-6 w-6 text-green-600" /> <TrendingUp className="h-6 w-6 text-green-600" />
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
<div className="bg-white rounded-lg shadow p-6"> <Card>
<CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600"> <p className="text-sm font-medium text-muted-foreground">
Total Accounts Total Accounts
</p> </p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-foreground">
{totalAccounts} {totalAccounts}
</p> </p>
</div> </div>
<div className="p-3 bg-blue-100 rounded-full"> <div className="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-full">
<CreditCard className="h-6 w-6 text-blue-600" /> <CreditCard className="h-6 w-6 text-blue-600" />
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
<div className="bg-white rounded-lg shadow p-6"> <Card>
<CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-600"> <p className="text-sm font-medium text-muted-foreground">
Connected Banks Connected Banks
</p> </p>
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p> <p className="text-2xl font-bold text-foreground">
{uniqueBanks}
</p>
</div> </div>
<div className="p-3 bg-purple-100 rounded-full"> <div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
<Building2 className="h-6 w-6 text-purple-600" /> <Building2 className="h-6 w-6 text-purple-600" />
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
</div> </div>
{/* Accounts List */} {/* Accounts List */}
<div className="bg-white rounded-lg shadow"> <Card>
<div className="px-6 py-4 border-b border-gray-200"> <CardHeader>
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3> <CardTitle>Bank Accounts</CardTitle>
<p className="text-sm text-gray-600"> <CardDescription>Manage your connected bank accounts</CardDescription>
Manage your connected bank accounts </CardHeader>
</p>
</div>
{!accounts || accounts.length === 0 ? ( {!accounts || accounts.length === 0 ? (
<div className="p-6 text-center"> <CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" /> <CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
No accounts found No accounts found
</h3> </h3>
<p className="text-gray-600"> <p className="text-muted-foreground">
Connect your first bank account to get started with Leggen. Connect your first bank account to get started with Leggen.
</p> </p>
</div> </CardContent>
) : ( ) : (
<div className="divide-y divide-gray-200"> <CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => { {accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query // Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0]; const accountBalance = account.balances?.[0];
@@ -224,7 +235,9 @@ export default function AccountsOverview() {
(b) => b.account_id === account.id, (b) => b.account_id === account.id,
); );
const balance = const balance =
accountBalance?.amount || fallbackBalance?.balance_amount || 0; accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency = const currency =
accountBalance?.currency || accountBalance?.currency ||
fallbackBalance?.currency || fallbackBalance?.currency ||
@@ -235,13 +248,13 @@ export default function AccountsOverview() {
return ( return (
<div <div
key={account.id} key={account.id}
className="p-4 sm:p-6 hover:bg-gray-50 transition-colors" className="p-4 sm:p-6 hover:bg-accent transition-colors"
> >
{/* Mobile layout - stack vertically */} {/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1"> <div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-gray-100 rounded-full"> <div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-gray-600" /> <Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{editingAccountId === account.id ? ( {editingAccountId === account.id ? (
@@ -250,8 +263,10 @@ export default function AccountsOverview() {
<input <input
type="text" type="text"
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} onChange={(e) =>
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" 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" placeholder="Account name"
name="search" name="search"
autoComplete="off" autoComplete="off"
@@ -280,29 +295,29 @@ export default function AccountsOverview() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<p className="text-sm text-gray-600 truncate"> <p className="text-sm text-muted-foreground truncate">
{account.institution_id} {account.institution_id}
</p> </p>
</div> </div>
) : ( ) : (
<div> <div>
<div className="flex items-center space-x-2 min-w-0"> <div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-gray-900 truncate"> <h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.name || "Unnamed Account"} {account.name || "Unnamed Account"}
</h4> </h4>
<button <button
onClick={() => handleEditStart(account)} onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors" className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Edit account name" title="Edit account name"
> >
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</button> </button>
</div> </div>
<p className="text-sm text-gray-600 truncate"> <p className="text-sm text-muted-foreground truncate">
{account.institution_id} {account.institution_id}
</p> </p>
{account.iban && ( {account.iban && (
<p className="text-xs text-gray-500 mt-1 font-mono break-all sm:break-normal"> <p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban} IBAN: {account.iban}
</p> </p>
)} )}
@@ -329,9 +344,11 @@ export default function AccountsOverview() {
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div> <div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div> </div>
</div> </div>
<p className="text-xs sm:text-sm text-gray-500 whitespace-nowrap"> <p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "} Updated{" "}
{formatDate(account.last_accessed || account.created)} {formatDate(
account.last_accessed || account.created,
)}
</p> </p>
</div> </div>
@@ -356,8 +373,9 @@ export default function AccountsOverview() {
); );
})} })}
</div> </div>
</CardContent>
)} )}
</div> </Card>
</div> </div>
); );
} }

View File

@@ -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<TabType>("overview");
const [sidebarOpen, setSidebarOpen] = useState(false);
const { data: accounts } = useQuery<Account[]>({
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 (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<CreditCard className="h-8 w-8 text-blue-600" />
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="px-6 py-4">
<div className="space-y-1">
{navigation.map((item) => (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
setSidebarOpen(false);
}}
className={cn(
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
activeTab === item.id
? "bg-blue-100 text-blue-700"
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100",
)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</button>
))}
</div>
</nav>
{/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
}).format(totalBalance)}
</p>
<p className="text-sm text-gray-500 mt-1">
{accounts?.length || 0} accounts
</p>
</div>
</div>
</div>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
>
<Menu className="h-6 w-6" />
</button>
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
{navigation.find((item) => item.id === activeTab)?.name}
</h2>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
{healthLoading ? (
<>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span>
</>
) : healthError || healthStatus?.status !== "healthy" ? (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
</>
) : (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
</>
)}
</div>
</div>
</div>
</header>
{/* Main content area */}
<main className="flex-1 overflow-y-auto p-6">
<ErrorBoundary>
{activeTab === "overview" && <AccountsOverview />}
{activeTab === "transactions" && <TransactionsTable />}
{activeTab === "analytics" && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Analytics
</h3>
<p className="text-gray-600">
Analytics dashboard coming soon...
</p>
</div>
)}
{activeTab === "notifications" && <Notifications />}
</ErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router";
import { Menu, Activity, Wifi, WifiOff } from "lucide-react"; import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { ThemeToggle } from "./ui/theme-toggle";
const navigation = [ const navigation = [
{ name: "Overview", to: "/" }, { name: "Overview", to: "/" },
@@ -31,25 +32,27 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
}); });
return ( return (
<header className="bg-white shadow-sm border-b border-gray-200"> <header className="bg-card shadow-sm border-b border-border">
<div className="flex items-center justify-between h-16 px-6"> <div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center"> <div className="flex items-center">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500" className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
> >
<Menu className="h-6 w-6" /> <Menu className="h-6 w-6" />
</button> </button>
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4"> <h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
{currentPage} {currentPage}
</h2> </h2>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{healthLoading ? ( {healthLoading ? (
<> <>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" /> <Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span> <span className="text-sm text-muted-foreground">
Checking...
</span>
</> </>
) : healthError || healthStatus?.status !== "healthy" ? ( ) : healthError || healthStatus?.status !== "healthy" ? (
<> <>
@@ -59,10 +62,11 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
) : ( ) : (
<> <>
<Wifi className="h-4 w-4 text-green-500" /> <Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span> <span className="text-sm text-muted-foreground">Connected</span>
</> </>
)} )}
</div> </div>
<ThemeToggle />
</div> </div>
</div> </div>
</header> </header>

View File

@@ -13,6 +13,24 @@ import {
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import LoadingSpinner from "./LoadingSpinner"; 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"; import type { NotificationSettings, NotificationService } from "../types/api";
export default function Notifications() { export default function Notifications() {
@@ -63,38 +81,35 @@ export default function Notifications() {
if (settingsLoading || servicesLoading) { if (settingsLoading || servicesLoading) {
return ( return (
<div className="bg-white rounded-lg shadow"> <Card>
<LoadingSpinner message="Loading notifications..." /> <LoadingSpinner message="Loading notifications..." />
</div> </Card>
); );
} }
if (settingsError || servicesError) { if (settingsError || servicesError) {
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <Alert variant="destructive">
<div className="flex items-center justify-center text-center"> <AlertCircle className="h-4 w-4" />
<div> <AlertTitle>Failed to load notifications</AlertTitle>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" /> <AlertDescription className="space-y-3">
<h3 className="text-lg font-medium text-gray-900 mb-2"> <p>
Failed to load notifications Unable to connect to the Leggen API. Please check your configuration
</h3> and ensure the API server is running.
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p> </p>
<button <Button
onClick={() => { onClick={() => {
refetchSettings(); refetchSettings();
refetchServices(); refetchServices();
}} }}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" variant="outline"
size="sm"
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Retry Retry
</button> </Button>
</div> </AlertDescription>
</div> </Alert>
</div>
); );
} }
@@ -120,103 +135,101 @@ export default function Notifications() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Test Notification Section */} {/* Test Notification Section */}
<div className="bg-white rounded-lg shadow p-6"> <Card>
<div className="flex items-center space-x-2 mb-4"> <CardHeader>
<TestTube className="h-5 w-5 text-blue-600" /> <CardTitle className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900"> <TestTube className="h-5 w-5 text-primary" />
Test Notifications <span>Test Notifications</span>
</h3> </CardTitle>
</div> </CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <Label htmlFor="service" className="text-foreground">
Service Service
</label> </Label>
<select <Select value={testService} onValueChange={setTestService}>
value={testService} <SelectTrigger>
onChange={(e) => setTestService(e.target.value)} <SelectValue placeholder="Select a service..." />
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" </SelectTrigger>
> <SelectContent>
<option value="">Select a service...</option>
{services?.map((service) => ( {services?.map((service) => (
<option key={service.name} value={service.name}> <SelectItem key={service.name} value={service.name}>
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"} {service.name}{" "}
</option> {service.enabled ? "(Enabled)" : "(Disabled)"}
</SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <Label htmlFor="message" className="text-foreground">
Message Message
</label> </Label>
<input <Input
id="message"
type="text" type="text"
value={testMessage} value={testMessage}
onChange={(e) => setTestMessage(e.target.value)} onChange={(e) => 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..." placeholder="Test message..."
/> />
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<button <Button
onClick={handleTestNotification} onClick={handleTestNotification}
disabled={!testService || testMutation.isPending} disabled={!testService || testMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<Send className="h-4 w-4 mr-2" /> <Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"} {testMutation.isPending ? "Sending..." : "Send Test Notification"}
</button> </Button>
</div>
</div> </div>
</CardContent>
</Card>
{/* Notification Services */} {/* Notification Services */}
<div className="bg-white rounded-lg shadow"> <Card>
<div className="px-6 py-4 border-b border-gray-200"> <CardHeader>
<div className="flex items-center space-x-2"> <CardTitle className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-blue-600" /> <Bell className="h-5 w-5 text-primary" />
<h3 className="text-lg font-medium text-gray-900"> <span>Notification Services</span>
Notification Services </CardTitle>
</h3> <CardDescription>Manage your notification services</CardDescription>
</div> </CardHeader>
<p className="text-sm text-gray-600 mt-1">
Manage your notification services
</p>
</div>
{!services || services.length === 0 ? ( {!services || services.length === 0 ? (
<div className="p-6 text-center"> <CardContent className="text-center">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" /> <Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
No notification services configured No notification services configured
</h3> </h3>
<p className="text-gray-600"> <p className="text-muted-foreground">
Configure notification services in your backend to receive alerts. Configure notification services in your backend to receive alerts.
</p> </p>
</div> </CardContent>
) : ( ) : (
<div className="divide-y divide-gray-200"> <CardContent className="p-0">
<div className="divide-y divide-border">
{services.map((service) => ( {services.map((service) => (
<div <div
key={service.name} key={service.name}
className="p-6 hover:bg-gray-50 transition-colors" className="p-6 hover:bg-accent transition-colors"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full"> <div className="p-3 bg-muted rounded-full">
{service.name.toLowerCase().includes("discord") ? ( {service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-gray-600" /> <MessageSquare className="h-6 w-6 text-muted-foreground" />
) : service.name.toLowerCase().includes("telegram") ? ( ) : service.name.toLowerCase().includes("telegram") ? (
<Send className="h-6 w-6 text-gray-600" /> <Send className="h-6 w-6 text-muted-foreground" />
) : ( ) : (
<Bell className="h-6 w-6 text-gray-600" /> <Bell className="h-6 w-6 text-muted-foreground" />
)} )}
</div> </div>
<div> <div>
<h4 className="text-lg font-medium text-gray-900 capitalize"> <h4 className="text-lg font-medium text-foreground capitalize">
{service.name} {service.name}
</h4> </h4>
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
@@ -241,61 +254,63 @@ export default function Notifications() {
: "bg-yellow-100 text-yellow-800" : "bg-yellow-100 text-yellow-800"
}`} }`}
> >
{service.configured ? "Configured" : "Not Configured"} {service.configured
? "Configured"
: "Not Configured"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <Button
<button
onClick={() => handleDeleteService(service.name)} onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending} disabled={deleteServiceMutation.isPending}
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors" variant="ghost"
title={`Delete ${service.name} service`} size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </Button>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</CardContent>
)} )}
</div> </Card>
{/* Notification Settings */} {/* Notification Settings */}
<div className="bg-white rounded-lg shadow p-6"> <Card>
<div className="flex items-center space-x-2 mb-4"> <CardHeader>
<Settings className="h-5 w-5 text-blue-600" /> <CardTitle className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900"> <Settings className="h-5 w-5 text-primary" />
Notification Settings <span>Notification Settings</span>
</h3> </CardTitle>
</div> </CardHeader>
<CardContent>
{settings && ( {settings && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="text-sm font-medium text-gray-700 mb-2"> <h4 className="text-sm font-medium text-foreground mb-2">
Filters Filters
</h4> </h4>
<div className="bg-gray-50 rounded-md p-4"> <div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1"> <Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Insensitive Filters Case Insensitive Filters
</label> </Label>
<p className="text-sm text-gray-900"> <p className="text-sm text-foreground">
{settings.filters.case_insensitive.length > 0 {settings.filters.case_insensitive.length > 0
? settings.filters.case_insensitive.join(", ") ? settings.filters.case_insensitive.join(", ")
: "None"} : "None"}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1"> <Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Sensitive Filters Case Sensitive Filters
</label> </Label>
<p className="text-sm text-gray-900"> <p className="text-sm text-foreground">
{settings.filters.case_sensitive && {settings.filters.case_sensitive &&
settings.filters.case_sensitive.length > 0 settings.filters.case_sensitive.length > 0
? settings.filters.case_sensitive.join(", ") ? settings.filters.case_sensitive.join(", ")
@@ -306,7 +321,7 @@ export default function Notifications() {
</div> </div>
</div> </div>
<div className="text-sm text-gray-600"> <div className="text-sm text-muted-foreground">
<p> <p>
Configure notification settings through your backend API to Configure notification settings through your backend API to
customize filters and service configurations. customize filters and service configurations.
@@ -314,7 +329,8 @@ export default function Notifications() {
</div> </div>
</div> </div>
)} )}
</div> </CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { X, Copy, Check } from "lucide-react"; import { X, Copy, Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "./ui/button";
import type { RawTransactionData } from "../types/api"; import type { RawTransactionData } from "../types/api";
interface RawTransactionModalProps { interface RawTransactionModalProps {
@@ -38,26 +39,27 @@ export default function RawTransactionModal({
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */} {/* Background overlay */}
<div <div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity"
onClick={onClose} onClick={onClose}
/> />
{/* Modal panel */} {/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full"> <div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full border">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-card px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-foreground">
Raw Transaction Data Raw Transaction Data
</h3> </h3>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <Button
onClick={handleCopy} onClick={handleCopy}
disabled={!rawTransaction} disabled={!rawTransaction}
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
> >
{copied ? ( {copied ? (
<> <>
<Check className="h-4 w-4 mr-1 text-green-600" /> <Check className="h-4 w-4 mr-1 text-green-600 dark:text-green-400" />
Copied! Copied!
</> </>
) : ( ) : (
@@ -66,37 +68,34 @@ export default function RawTransactionModal({
Copy JSON Copy JSON
</> </>
)} )}
</button> </Button>
<button <Button onClick={onClose} variant="ghost" size="sm">
onClick={onClose}
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </Button>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-muted-foreground">
Transaction ID:{" "} Transaction ID:{" "}
<code className="bg-gray-100 px-2 py-1 rounded text-xs"> <code className="bg-muted px-2 py-1 rounded text-xs text-foreground">
{transactionId} {transactionId}
</code> </code>
</p> </p>
</div> </div>
{rawTransaction ? ( {rawTransaction ? (
<div className="bg-gray-50 rounded-lg p-4 overflow-auto max-h-96"> <div className="bg-muted rounded-lg p-4 overflow-auto max-h-96">
<pre className="text-sm text-gray-800 whitespace-pre-wrap"> <pre className="text-sm text-foreground whitespace-pre-wrap">
{JSON.stringify(rawTransaction, null, 2)} {JSON.stringify(rawTransaction, null, 2)}
</pre> </pre>
</div> </div>
) : ( ) : (
<div className="bg-gray-50 rounded-lg p-8 text-center"> <div className="bg-muted rounded-lg p-8 text-center">
<p className="text-gray-600"> <p className="text-foreground">
Raw transaction data is not available for this transaction. Raw transaction data is not available for this transaction.
</p> </p>
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-muted-foreground mt-2">
Try refreshing the page or check if the transaction was Try refreshing the page or check if the transaction was
fetched with summary_only=false. fetched with summary_only=false.
</p> </p>
@@ -104,14 +103,14 @@ export default function RawTransactionModal({
)} )}
</div> </div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <div className="bg-muted/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button <Button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" className="w-full sm:ml-3 sm:w-auto"
> >
Close Close
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -43,22 +43,22 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
return ( return (
<div <div
className={cn( className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0", "fixed inset-y-0 left-0 z-50 w-64 bg-card shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full", sidebarOpen ? "translate-x-0" : "-translate-x-full",
)} )}
> >
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200"> <div className="flex items-center justify-between h-16 px-6 border-b border-border">
<Link <Link
to="/" to="/"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className="flex items-center space-x-2 hover:opacity-80 transition-opacity" className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
> >
<CreditCard className="h-8 w-8 text-blue-600" /> <CreditCard className="h-8 w-8 text-primary" />
<h1 className="text-xl font-bold text-gray-900">Leggen</h1> <h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
</Link> </Link>
<button <button
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500" className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
> >
<X className="h-6 w-6" /> <X className="h-6 w-6" />
</button> </button>
@@ -71,11 +71,12 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={() => setSidebarOpen(false)} 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 location.pathname === item.to
? "bg-blue-100 text-blue-700" ? "bg-primary text-primary-foreground"
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100" : "text-card-foreground hover:text-card-foreground hover:bg-accent",
}`} )}
> >
<item.icon className="mr-3 h-5 w-5" /> <item.icon className="mr-3 h-5 w-5" />
{item.name} {item.name}
@@ -85,18 +86,18 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
</nav> </nav>
{/* Account Summary in Sidebar */} {/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-gray-200 mt-auto"> <div className="px-6 py-4 border-t border-border mt-auto">
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600"> <span className="text-sm font-medium text-muted-foreground">
Total Balance Total Balance
</span> </span>
<TrendingUp className="h-4 w-4 text-green-500" /> <TrendingUp className="h-4 w-4 text-green-500" />
</div> </div>
<p className="text-2xl font-bold text-gray-900 mt-1"> <p className="text-2xl font-bold text-foreground mt-1">
{formatCurrency(totalBalance)} {formatCurrency(totalBalance)}
</p> </p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-muted-foreground mt-1">
{accounts?.length || 0} accounts {accounts?.length || 0} accounts
</p> </p>
</div> </div>

View File

@@ -5,7 +5,7 @@ interface TransactionSkeletonProps {
export default function TransactionSkeleton({ export default function TransactionSkeleton({
rows = 5, rows = 5,
view = "table" view = "table",
}: TransactionSkeletonProps) { }: TransactionSkeletonProps) {
const skeletonRows = Array.from({ length: rows }, (_, index) => index); const skeletonRows = Array.from({ length: rows }, (_, index) => index);

View File

@@ -27,6 +27,10 @@ import TransactionSkeleton from "./TransactionSkeleton";
import FiltersSkeleton from "./FiltersSkeleton"; import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal"; import RawTransactionModal from "./RawTransactionModal";
import { FilterBar, type FilterState } from "./filters"; 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"; import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
export default function TransactionsTable() { export default function TransactionsTable() {
@@ -50,7 +54,9 @@ export default function TransactionsTable() {
const [perPage, setPerPage] = useState(50); const [perPage, setPerPage] = useState(50);
// Debounced search state // Debounced search state
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(
filterState.searchTerm,
);
// Table state (remove pagination from table) // Table state (remove pagination from table)
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -128,8 +134,12 @@ export default function TransactionsTable() {
perPage: perPage, perPage: perPage,
search: debouncedSearchTerm || undefined, search: debouncedSearchTerm || undefined,
summaryOnly: false, summaryOnly: false,
minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined, minAmount: filterState.minAmount
maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined, ? parseFloat(filterState.minAmount)
: undefined,
maxAmount: filterState.maxAmount
? parseFloat(filterState.maxAmount)
: undefined,
}), }),
}); });
@@ -149,7 +159,13 @@ export default function TransactionsTable() {
// Reset pagination when filters change // Reset pagination when filters change
useEffect(() => { useEffect(() => {
setCurrentPage(1); 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) => { const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction); setSelectedTransaction(transaction);
@@ -177,15 +193,15 @@ export default function TransactionsTable() {
const accountBalanceMap = new Map<string, number>(); const accountBalanceMap = new Map<string, number>();
// Create a map of account current balances // Create a map of account current balances
balances.forEach(balance => { balances.forEach((balance) => {
if (balance.balance_type === 'expected') { if (balance.balance_type === "expected") {
accountBalanceMap.set(balance.account_id, balance.balance_amount); accountBalanceMap.set(balance.account_id, balance.balance_amount);
} }
}); });
// Group transactions by account // Group transactions by account
const transactionsByAccount = new Map<string, Transaction[]>(); const transactionsByAccount = new Map<string, Transaction[]>();
transactions.forEach(txn => { transactions.forEach((txn) => {
if (!transactionsByAccount.has(txn.account_id)) { if (!transactionsByAccount.has(txn.account_id)) {
transactionsByAccount.set(txn.account_id, []); transactionsByAccount.set(txn.account_id, []);
} }
@@ -198,13 +214,16 @@ export default function TransactionsTable() {
let runningBalance = currentBalance; let runningBalance = currentBalance;
// Sort transactions by date (newest first) to work backwards // Sort transactions by date (newest first) to work backwards
const sortedTransactions = [...accountTransactions].sort((a, b) => const sortedTransactions = [...accountTransactions].sort(
new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime() (a, b) =>
new Date(b.transaction_date).getTime() -
new Date(a.transaction_date).getTime(),
); );
// Calculate running balance by working backwards from current balance // Calculate running balance by working backwards from current balance
sortedTransactions.forEach((txn) => { sortedTransactions.forEach((txn) => {
runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance; runningBalances[`${txn.account_id}-${txn.transaction_id}`] =
runningBalance;
runningBalance -= txn.transaction_value; runningBalance -= txn.transaction_value;
}); });
}); });
@@ -240,10 +259,10 @@ export default function TransactionsTable() {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate"> <h4 className="text-sm font-medium text-foreground truncate">
{transaction.description} {transaction.description}
</h4> </h4>
<div className="text-xs text-gray-500 space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
{account && ( {account && (
<p className="truncate"> <p className="truncate">
{account.name || "Unnamed Account"} {" "} {account.name || "Unnamed Account"} {" "}
@@ -289,7 +308,9 @@ export default function TransactionsTable() {
}, },
sortingFn: "basic", sortingFn: "basic",
}, },
...(showRunningBalance ? [{ ...(showRunningBalance
? [
{
id: "running_balance", id: "running_balance",
header: "Running Balance", header: "Running Balance",
cell: ({ row }: { row: { original: Transaction } }) => { cell: ({ row }: { row: { original: Transaction } }) => {
@@ -301,26 +322,28 @@ export default function TransactionsTable() {
return ( return (
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium text-gray-900"> <p className="text-sm font-medium text-foreground">
{formatCurrency(balance, transaction.transaction_currency)} {formatCurrency(balance, transaction.transaction_currency)}
</p> </p>
</div> </div>
); );
}, },
}] : []), },
]
: []),
{ {
accessorKey: "transaction_date", accessorKey: "transaction_date",
header: "Date", header: "Date",
cell: ({ row }) => { cell: ({ row }) => {
const transaction = row.original; const transaction = row.original;
return ( return (
<div className="text-sm text-gray-900"> <div className="text-sm text-foreground">
{transaction.transaction_date {transaction.transaction_date
? formatDate(transaction.transaction_date) ? formatDate(transaction.transaction_date)
: "No date"} : "No date"}
{transaction.booking_date && {transaction.booking_date &&
transaction.booking_date !== transaction.transaction_date && ( transaction.booking_date !== transaction.transaction_date && (
<p className="text-xs text-gray-400"> <p className="text-xs text-muted-foreground">
Booked: {formatDate(transaction.booking_date)} Booked: {formatDate(transaction.booking_date)}
</p> </p>
)} )}
@@ -337,7 +360,7 @@ export default function TransactionsTable() {
return ( return (
<button <button
onClick={() => handleViewRaw(transaction)} onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors" className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
title="View raw transaction data" title="View raw transaction data"
> >
<Eye className="h-3 w-3 mr-1" /> <Eye className="h-3 w-3 mr-1" />
@@ -361,7 +384,8 @@ export default function TransactionsTable() {
columnFilters, columnFilters,
globalFilter: filterState.searchTerm, globalFilter: filterState.searchTerm,
}, },
onGlobalFilterChange: (value: string) => handleFilterChange("searchTerm", value), onGlobalFilterChange: (value: string) =>
handleFilterChange("searchTerm", value),
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: (row, _columnId, filterValue) => {
// Custom global filter that searches multiple fields // Custom global filter that searches multiple fields
const transaction = row.original; const transaction = row.original;
@@ -395,26 +419,21 @@ export default function TransactionsTable() {
if (transactionsError) { if (transactionsError) {
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <Alert variant="destructive">
<div className="flex items-center justify-center text-center"> <AlertCircle className="h-4 w-4" />
<div> <AlertTitle>Failed to load transactions</AlertTitle>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" /> <AlertDescription className="space-y-3">
<h3 className="text-lg font-medium text-gray-900 mb-2"> <p>Unable to fetch transactions from the Leggen API.</p>
Failed to load transactions <Button
</h3>
<p className="text-gray-600 mb-4">
Unable to fetch transactions from the Leggen API.
</p>
<button
onClick={() => refetchTransactions()} onClick={() => refetchTransactions()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" variant="outline"
size="sm"
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Retry Retry
</button> </Button>
</div> </AlertDescription>
</div> </Alert>
</div>
); );
} }
@@ -428,13 +447,15 @@ export default function TransactionsTable() {
accounts={accounts} accounts={accounts}
isSearchLoading={isSearchLoading} isSearchLoading={isSearchLoading}
showRunningBalance={showRunningBalance} showRunningBalance={showRunningBalance}
onToggleRunningBalance={() => setShowRunningBalance(!showRunningBalance)} onToggleRunningBalance={() =>
setShowRunningBalance(!showRunningBalance)
}
/> />
{/* Results Summary */} {/* Results Summary */}
<div className="bg-white rounded-lg shadow border"> <Card>
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200"> <CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<p className="text-sm text-gray-600"> <p className="text-sm text-muted-foreground">
Showing {transactions.length} transaction Showing {transactions.length} transaction
{transactions.length !== 1 ? "s" : ""} ( {transactions.length !== 1 ? "s" : ""} (
{pagination ? ( {pagination ? (
@@ -452,26 +473,30 @@ export default function TransactionsTable() {
) )
{filterState.selectedAccount && accounts && ( {filterState.selectedAccount && accounts && (
<span className="ml-1"> <span className="ml-1">
for {accounts.find((acc) => acc.id === filterState.selectedAccount)?.name} for{" "}
{
accounts.find((acc) => acc.id === filterState.selectedAccount)
?.name
}
</span> </span>
)} )}
</p> </p>
</div> </CardContent>
</div> </Card>
{/* Responsive Table/Cards */} {/* Responsive Table/Cards */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <Card className="overflow-hidden">
{/* Desktop Table View (hidden on mobile) */} {/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block"> <div className="hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-border">
<thead className="bg-gray-50"> <thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th <th
key={header.id} key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
onClick={header.column.getToggleSortingHandler()} onClick={header.column.getToggleSortingHandler()}
> >
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
@@ -488,15 +513,15 @@ export default function TransactionsTable() {
<ChevronUp <ChevronUp
className={`h-3 w-3 ${ className={`h-3 w-3 ${
header.column.getIsSorted() === "asc" header.column.getIsSorted() === "asc"
? "text-blue-600" ? "text-primary"
: "text-gray-400" : "text-muted-foreground"
}`} }`}
/> />
<ChevronDown <ChevronDown
className={`h-3 w-3 -mt-1 ${ className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc" header.column.getIsSorted() === "desc"
? "text-blue-600" ? "text-primary"
: "text-gray-400" : "text-muted-foreground"
}`} }`}
/> />
</div> </div>
@@ -507,20 +532,20 @@ export default function TransactionsTable() {
</tr> </tr>
))} ))}
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-card divide-y divide-border">
{table.getRowModel().rows.length === 0 ? ( {table.getRowModel().rows.length === 0 ? (
<tr> <tr>
<td <td
colSpan={columns.length} colSpan={columns.length}
className="px-6 py-12 text-center" className="px-6 py-12 text-center"
> >
<div className="text-gray-400 mb-4"> <div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" /> <TrendingUp className="h-12 w-12 mx-auto" />
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
No transactions found No transactions found
</h3> </h3>
<p className="text-gray-600"> <p className="text-muted-foreground">
{hasActiveFilters {hasActiveFilters
? "Try adjusting your filters to see more results." ? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."} : "No transactions are available for the selected criteria."}
@@ -529,9 +554,12 @@ export default function TransactionsTable() {
</tr> </tr>
) : ( ) : (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50"> <tr key={row.id} className="hover:bg-muted/50">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap"> <td
key={cell.id}
className="px-6 py-4 whitespace-nowrap"
>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext(),
@@ -550,20 +578,20 @@ export default function TransactionsTable() {
<div className="md:hidden"> <div className="md:hidden">
{table.getRowModel().rows.length === 0 ? ( {table.getRowModel().rows.length === 0 ? (
<div className="px-6 py-12 text-center"> <div className="px-6 py-12 text-center">
<div className="text-gray-400 mb-4"> <div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" /> <TrendingUp className="h-12 w-12 mx-auto" />
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
No transactions found No transactions found
</h3> </h3>
<p className="text-gray-600"> <p className="text-muted-foreground">
{hasActiveFilters {hasActiveFilters
? "Try adjusting your filters to see more results." ? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."} : "No transactions are available for the selected criteria."}
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-border">
{table.getRowModel().rows.map((row) => { {table.getRowModel().rows.map((row) => {
const transaction = row.original; const transaction = row.original;
const account = accounts?.find( const account = accounts?.find(
@@ -574,7 +602,7 @@ export default function TransactionsTable() {
return ( return (
<div <div
key={row.id} key={row.id}
className="p-4 hover:bg-gray-50 transition-colors" className="p-4 hover:bg-muted/50 transition-colors"
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -591,33 +619,39 @@ export default function TransactionsTable() {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 break-words"> <h4 className="text-sm font-medium text-foreground break-words">
{transaction.description} {transaction.description}
</h4> </h4>
<div className="text-xs text-gray-500 space-y-1 mt-1"> <div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && ( {account && (
<p className="break-words"> <p className="break-words">
{account.name || "Unnamed Account"} {" "} {account.name || "Unnamed Account"} {" "}
{account.institution_id} {account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || transaction.debtor_name) && ( {(transaction.creditor_name ||
transaction.debtor_name) && (
<p className="break-words"> <p className="break-words">
{isPositive ? "From: " : "To: "} {isPositive ? "From: " : "To: "}
{transaction.creditor_name || transaction.debtor_name} {transaction.creditor_name ||
transaction.debtor_name}
</p> </p>
)} )}
{transaction.reference && ( {transaction.reference && (
<p className="break-words">Ref: {transaction.reference}</p> <p className="break-words">
Ref: {transaction.reference}
</p>
)} )}
<p className="text-gray-400"> <p className="text-muted-foreground">
{transaction.transaction_date {transaction.transaction_date
? formatDate(transaction.transaction_date) ? formatDate(transaction.transaction_date)
: "No date"} : "No date"}
{transaction.booking_date && {transaction.booking_date &&
transaction.booking_date !== transaction.transaction_date && ( transaction.booking_date !==
transaction.transaction_date && (
<span className="ml-2"> <span className="ml-2">
(Booked: {formatDate(transaction.booking_date)}) (Booked:{" "}
{formatDate(transaction.booking_date)})
</span> </span>
)} )}
</p> </p>
@@ -638,16 +672,19 @@ export default function TransactionsTable() {
)} )}
</p> </p>
{showRunningBalance && ( {showRunningBalance && (
<p className="text-xs text-gray-500 mb-1"> <p className="text-xs text-muted-foreground mb-1">
Balance: {formatCurrency( Balance:{" "}
runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0, {formatCurrency(
runningBalances[
`${transaction.account_id}-${transaction.transaction_id}`
] || 0,
transaction.transaction_currency, transaction.transaction_currency,
)} )}
</p> </p>
)} )}
<button <button
onClick={() => handleViewRaw(transaction)} onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors" className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
title="View raw transaction data" title="View raw transaction data"
> >
<Eye className="h-3 w-3 mr-1" /> <Eye className="h-3 w-3 mr-1" />
@@ -664,141 +701,18 @@ export default function TransactionsTable() {
{/* Pagination */} {/* Pagination */}
{pagination && ( {pagination && (
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0"> <DataTablePagination
{/* Mobile pagination controls */} currentPage={pagination.page}
<div className="flex justify-between w-full sm:hidden"> totalPages={pagination.total_pages}
<div className="flex space-x-2"> pageSize={pagination.per_page}
<button total={pagination.total}
onClick={() => setCurrentPage(1)} hasNext={pagination.has_next}
disabled={pagination.page === 1} hasPrev={pagination.has_prev}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" onPageChange={setCurrentPage}
> onPageSizeChange={setPerPage}
First />
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
</div>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
{/* Mobile pagination info */}
<div className="text-center w-full sm:hidden">
<p className="text-sm text-gray-700">
Page <span className="font-medium">{pagination.page}</span> of{" "}
<span className="font-medium">{pagination.total_pages}</span>
<br />
<span className="text-xs text-gray-500">
Showing {(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
</span>
</p>
</div>
{/* Desktop pagination */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(pagination.page - 1) * pagination.per_page + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)} )}
</span>{" "} </Card>
of <span className="font-medium">{pagination.total}</span>{" "}
results
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">
Rows per page:
</label>
<select
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value));
setCurrentPage(1); // Reset to first page when changing page size
}}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
{[10, 25, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page <span className="font-medium">{pagination.page}</span>{" "}
of{" "}
<span className="font-medium">
{pagination.total_pages}
</span>
</span>
<button
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Raw Transaction Modal */} {/* Raw Transaction Modal */}
<RawTransactionModal <RawTransactionModal

View File

@@ -27,22 +27,39 @@ interface AggregatedDataPoint {
[key: string]: string | number; [key: string]: string | number;
} }
export default function BalanceChart({ data, accounts, className }: BalanceChartProps) { interface TooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
}>;
label?: string;
}
export default function BalanceChart({
data,
accounts,
className,
}: BalanceChartProps) {
// Create a lookup map for account info // Create a lookup map for account info
const accountMap = accounts.reduce((map, account) => { const accountMap = accounts.reduce(
(map, account) => {
map[account.id] = account; map[account.id] = account;
return map; return map;
}, {} as Record<string, Account>); },
{} as Record<string, Account>,
);
// Helper function to get bank name from institution_id // Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => { const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = { const bankMapping: Record<string, string> = {
'REVOLUT_REVOLT21': 'Revolut', REVOLUT_REVOLT21: "Revolut",
'NUBANK_NUPBBR25': 'Nu Pagamentos', NUBANK_NUPBBR25: "Nu Pagamentos",
'BANCOBPI_BBPIPTPL': 'Banco BPI', BANCOBPI_BBPIPTPL: "Banco BPI",
// Add more mappings as needed // 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 // Helper function to create display name for account
@@ -50,20 +67,24 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
const account = accountMap[accountId]; const account = accountMap[accountId];
if (account) { if (account) {
const bankName = getBankName(account.institution_id); 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 `${bankName} - ${accountName}`;
} }
return `Account ${accountId.split('-')[1]}`; return `Account ${accountId.split("-")[1]}`;
}; };
// Process balance data for the chart // Process balance data for the chart
const chartData = data const chartData = data
.filter((balance) => balance.balance_type === "closingBooked") .filter((balance) => balance.balance_type === "closingBooked")
.map((balance) => ({ .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, balance: balance.balance_amount,
account_id: balance.account_id, 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 // Group by account and aggregate
const accountBalances: { [key: string]: ChartDataPoint[] } = {}; const accountBalances: { [key: string]: ChartDataPoint[] } = {};
@@ -86,18 +107,37 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
}); });
const finalData = Object.values(aggregatedData).sort( 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 colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">Date: {label}</p>
{payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}>
{getAccountDisplayName(entry.name)}:
{entry.value.toLocaleString()}
</p>
))}
</div>
);
}
return null;
};
if (finalData.length === 0) { if (finalData.length === 0) {
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Balance Progress
</h3> </h3>
<div className="h-80 flex items-center justify-center text-gray-500"> <div className="h-80 flex items-center justify-center text-muted-foreground">
No balance data available No balance data available
</div> </div>
</div> </div>
@@ -106,7 +146,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Over Time Balance Progress Over Time
</h3> </h3>
<div className="h-80"> <div className="h-80">
@@ -118,9 +158,9 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
tickFormatter={(value) => { tickFormatter={(value) => {
// Convert DD/MM/YYYY back to a proper date for formatting // 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); const date = new Date(year, month - 1, day);
return date.toLocaleDateString('en-GB', { return date.toLocaleDateString("en-GB", {
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
@@ -130,13 +170,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`} tickFormatter={(value) => `${value.toLocaleString()}`}
/> />
<Tooltip <Tooltip content={<CustomTooltip />} />
formatter={(value: number, name: string) => [
`${value.toLocaleString()}`,
getAccountDisplayName(name),
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend /> <Legend />
{Object.keys(accountBalances).map((accountId, index) => ( {Object.keys(accountBalances).map((accountId, index) => (
<Area <Area

View File

@@ -15,7 +15,6 @@ interface MonthlyTrendsProps {
days?: number; days?: number;
} }
interface TooltipProps { interface TooltipProps {
active?: boolean; active?: boolean;
payload?: Array<{ payload?: Array<{
@@ -26,7 +25,10 @@ interface TooltipProps {
label?: string; 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 // Get pre-calculated monthly stats from the new endpoint
const { data: monthlyData, isLoading } = useQuery({ const { data: monthlyData, isLoading } = useQuery({
queryKey: ["monthly-stats", days], queryKey: ["monthly-stats", days],
@@ -49,11 +51,11 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
if (isLoading) { if (isLoading) {
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Monthly Spending Trends Monthly Spending Trends
</h3> </h3>
<div className="h-80 flex items-center justify-center"> <div className="h-80 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div> </div>
</div> </div>
); );
@@ -62,10 +64,10 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
if (displayData.length === 0) { if (displayData.length === 0) {
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Monthly Spending Trends Monthly Spending Trends
</h3> </h3>
<div className="h-80 flex items-center justify-center text-gray-500"> <div className="h-80 flex items-center justify-center text-muted-foreground">
No transaction data available No transaction data available
</div> </div>
</div> </div>
@@ -75,8 +77,8 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
const CustomTooltip = ({ active, payload, label }: TooltipProps) => { const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
return ( return (
<div className="bg-white p-3 border rounded shadow-lg"> <div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium">{label}</p> <p className="font-medium text-foreground">{label}</p>
{payload.map((entry, index) => ( {payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}> <p key={index} style={{ color: entry.color }}>
{entry.name}: {Math.abs(entry.value).toLocaleString()} {entry.name}: {Math.abs(entry.value).toLocaleString()}
@@ -98,12 +100,15 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
{getTitle(days)} {getTitle(days)}
</h3> </h3>
<div className="h-80"> <div className="h-80">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}> <BarChart
data={displayData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
dataKey="month" dataKey="month"
@@ -122,7 +127,7 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-4 flex justify-center space-x-6 text-sm"> <div className="mt-4 flex justify-center space-x-6 text-sm text-foreground">
<div className="flex items-center"> <div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-2" /> <div className="w-3 h-3 bg-green-500 rounded mr-2" />
<span>Income</span> <span>Income</span>

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import clsx from "clsx"; import { Card, CardContent } from "../ui/card";
import { cn } from "../../lib/utils";
interface StatCardProps { interface StatCardProps {
title: string; title: string;
@@ -22,30 +23,28 @@ export default function StatCard({
className, className,
}: StatCardProps) { }: StatCardProps) {
return ( return (
<div <Card className={cn(className)}>
className={clsx( <CardContent className="p-6">
"bg-white rounded-lg shadow p-6 border border-gray-200",
className
)}
>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Icon className="h-8 w-8 text-blue-600" /> <Icon className="h-8 w-8 text-primary" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate"> <dt className="text-sm font-medium text-muted-foreground truncate">
{title} {title}
</dt> </dt>
<dd className="flex items-baseline"> <dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900"> <div className="text-2xl font-semibold text-foreground">
{value} {value}
</div> </div>
{trend && ( {trend && (
<div <div
className={clsx( className={cn(
"ml-2 flex items-baseline text-sm font-semibold", "ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive ? "text-green-600" : "text-red-600" trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)} )}
> >
{trend.isPositive ? "+" : ""} {trend.isPositive ? "+" : ""}
@@ -54,11 +53,14 @@ export default function StatCard({
)} )}
</dd> </dd>
{subtitle && ( {subtitle && (
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd> <dd className="text-sm text-muted-foreground mt-1">
{subtitle}
</dd>
)} )}
</dl> </dl>
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
); );
} }

View File

@@ -1,4 +1,5 @@
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import { Button } from "../ui/button";
import type { TimePeriod } from "../../lib/timePeriods"; import type { TimePeriod } from "../../lib/timePeriods";
import { TIME_PERIODS } from "../../lib/timePeriods"; import { TIME_PERIODS } from "../../lib/timePeriods";
@@ -15,23 +16,22 @@ export default function TimePeriodFilter({
}: TimePeriodFilterProps) { }: TimePeriodFilterProps) {
return ( return (
<div className={`flex items-center gap-4 ${className}`}> <div className={`flex items-center gap-4 ${className}`}>
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-foreground">
<Calendar size={20} /> <Calendar size={20} />
<span className="font-medium">Time Period:</span> <span className="font-medium">Time Period:</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{TIME_PERIODS.map((period) => ( {TIME_PERIODS.map((period) => (
<button <Button
key={period.value} key={period.value}
onClick={() => onPeriodChange(period)} onClick={() => onPeriodChange(period)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${ variant={
selectedPeriod.value === period.value selectedPeriod.value === period.value ? "default" : "outline"
? "bg-blue-600 text-white" }
: "bg-gray-100 text-gray-700 hover:bg-gray-200" size="sm"
}`}
> >
{period.label} {period.label}
</button> </Button>
))} ))}
</div> </div>
</div> </div>

View File

@@ -33,18 +33,18 @@ export default function TransactionDistribution({
// Helper function to get bank name from institution_id // Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => { const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = { const bankMapping: Record<string, string> = {
'REVOLUT_REVOLT21': 'Revolut', REVOLUT_REVOLT21: "Revolut",
'NUBANK_NUPBBR25': 'Nu Pagamentos', NUBANK_NUPBBR25: "Nu Pagamentos",
'BANCOBPI_BBPIPTPL': 'Banco BPI', BANCOBPI_BBPIPTPL: "Banco BPI",
// TODO: Add more bank mappings as needed // 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 // Helper function to create display name for account
const getAccountDisplayName = (account: Account): string => { const getAccountDisplayName = (account: Account): string => {
const bankName = getBankName(account.institution_id); 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}`; return `${bankName} - ${accountName}`;
}; };
@@ -66,10 +66,10 @@ export default function TransactionDistribution({
if (pieData.length === 0 || totalBalance === 0) { if (pieData.length === 0 || totalBalance === 0) {
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Account Distribution Account Distribution
</h3> </h3>
<div className="h-80 flex items-center justify-center text-gray-500"> <div className="h-80 flex items-center justify-center text-muted-foreground">
No account data available No account data available
</div> </div>
</div> </div>
@@ -81,12 +81,12 @@ export default function TransactionDistribution({
const data = payload[0].payload; const data = payload[0].payload;
const percentage = ((data.value / totalBalance) * 100).toFixed(1); const percentage = ((data.value / totalBalance) * 100).toFixed(1);
return ( return (
<div className="bg-white p-3 border rounded shadow-lg"> <div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium">{data.name}</p> <p className="font-medium text-foreground">{data.name}</p>
<p className="text-blue-600"> <p className="text-primary">
Balance: {data.value.toLocaleString()} Balance: {data.value.toLocaleString()}
</p> </p>
<p className="text-gray-600">{percentage}% of total</p> <p className="text-muted-foreground">{percentage}% of total</p>
</div> </div>
); );
} }
@@ -95,7 +95,7 @@ export default function TransactionDistribution({
return ( return (
<div className={className}> <div className={className}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-foreground mb-4">
Account Balance Distribution Account Balance Distribution
</h3> </h3>
<div className="h-80"> <div className="h-80">
@@ -125,15 +125,20 @@ export default function TransactionDistribution({
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-2"> <div className="mt-4 grid grid-cols-1 gap-2">
{pieData.map((item, index) => ( {pieData.map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm"> <div
key={index}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-3 h-3 rounded-full mr-2" className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: item.color }} style={{ backgroundColor: item.color }}
/> />
<span className="text-gray-700">{item.name}</span> <span className="text-foreground">{item.name}</span>
</div> </div>
<span className="font-medium">{item.value.toLocaleString()}</span> <span className="font-medium text-foreground">
{item.value.toLocaleString()}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -11,7 +11,11 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } 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"; import type { Account } from "../../types/api";
export interface AccountComboboxProps { export interface AccountComboboxProps {
@@ -29,7 +33,9 @@ export function AccountCombobox({
}: AccountComboboxProps) { }: AccountComboboxProps) {
const [open, setOpen] = useState(false); 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 formatAccountName = (account: Account) => {
const displayName = account.name || "Unnamed Account"; const displayName = account.name || "Unnamed Account";
@@ -72,7 +78,7 @@ export function AccountCombobox({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
selectedAccount === "" ? "opacity-100" : "opacity-0" selectedAccount === "" ? "opacity-100" : "opacity-0",
)} )}
/> />
<Building2 className="mr-2 h-4 w-4 text-gray-400" /> <Building2 className="mr-2 h-4 w-4 text-gray-400" />
@@ -94,7 +100,7 @@ export function AccountCombobox({
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
selectedAccount === account.id selectedAccount === account.id
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">

View File

@@ -33,7 +33,9 @@ export function ActiveFilterChips({
// Account chip // Account chip
if (filterState.selectedAccount) { if (filterState.selectedAccount) {
const account = accounts.find((acc) => acc.id === filterState.selectedAccount); const account = accounts.find(
(acc) => acc.id === filterState.selectedAccount,
);
const accountName = account const accountName = account
? `${account.name || "Unnamed Account"} (${account.institution_id})` ? `${account.name || "Unnamed Account"} (${account.institution_id})`
: "Unknown Account"; : "Unknown Account";
@@ -69,8 +71,12 @@ export function ActiveFilterChips({
// Amount range chips // Amount range chips
if (filterState.minAmount || filterState.maxAmount) { if (filterState.minAmount || filterState.maxAmount) {
let amountLabel = "Amount: "; let amountLabel = "Amount: ";
const minAmount = filterState.minAmount ? parseFloat(filterState.minAmount) : null; const minAmount = filterState.minAmount
const maxAmount = filterState.maxAmount ? parseFloat(filterState.maxAmount) : null; ? parseFloat(filterState.minAmount)
: null;
const maxAmount = filterState.maxAmount
? parseFloat(filterState.maxAmount)
: null;
if (minAmount && maxAmount) { if (minAmount && maxAmount) {
amountLabel += `${minAmount} - €${maxAmount}`; amountLabel += `${minAmount} - €${maxAmount}`;

View File

@@ -2,7 +2,11 @@ import { useState } from "react";
import { MoreHorizontal, Euro } from "lucide-react"; import { MoreHorizontal, Euro } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { export interface AdvancedFiltersPopoverProps {
minAmount: string; minAmount: string;
@@ -94,7 +98,8 @@ export function AdvancedFiltersPopover({
{/* Future: Add transaction status filter */} {/* Future: Add transaction status filter */}
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
More filters coming soon: transaction status, categories, and more. More filters coming soon: transaction status, categories, and
more.
</div> </div>
</div> </div>

View File

@@ -6,7 +6,11 @@ import type { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; 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 { export interface DateRangePickerProps {
startDate: string; startDate: string;
@@ -39,7 +43,9 @@ const datePresets: DatePreset[] = [
const now = new Date(); const now = new Date();
const dayOfWeek = now.getDay(); const dayOfWeek = now.getDay();
const startOfWeek = new Date(now); 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); const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6); endOfWeek.setDate(startOfWeek.getDate() + 6);
@@ -111,12 +117,12 @@ export function DateRangePicker({
if (range?.from && range?.to) { if (range?.from && range?.to) {
onDateRangeChange( onDateRangeChange(
range.from.toISOString().split("T")[0], range.from.toISOString().split("T")[0],
range.to.toISOString().split("T")[0] range.to.toISOString().split("T")[0],
); );
} else if (range?.from && !range?.to) { } else if (range?.from && !range?.to) {
onDateRangeChange( onDateRangeChange(
range.from.toISOString().split("T")[0], 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" variant="outline"
className={cn( className={cn(
"justify-between text-left font-normal", "justify-between text-left font-normal",
!dateRange && "text-muted-foreground" !dateRange && "text-muted-foreground",
)} )}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -38,7 +38,6 @@ export function FilterBar({
onToggleRunningBalance, onToggleRunningBalance,
className, className,
}: FilterBarProps) { }: FilterBarProps) {
const hasActiveFilters = const hasActiveFilters =
filterState.searchTerm || filterState.searchTerm ||
filterState.selectedAccount || filterState.selectedAccount ||
@@ -53,11 +52,13 @@ export function FilterBar({
}; };
return ( return (
<div className={cn("bg-white rounded-lg shadow border", className)}> <div className={cn("bg-card rounded-lg shadow border", className)}>
{/* Main Filter Bar */} {/* Main Filter Bar */}
<div className="px-6 py-4"> <div className="px-6 py-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Transactions</h3> <h3 className="text-lg font-semibold text-card-foreground">
Transactions
</h3>
<Button <Button
onClick={onToggleRunningBalance} onClick={onToggleRunningBalance}
variant={showRunningBalance ? "default" : "outline"} variant={showRunningBalance ? "default" : "outline"}
@@ -71,16 +72,16 @@ export function FilterBar({
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search Input */} {/* Search Input */}
<div className="relative flex-1 min-w-[240px]"> <div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search transactions..." placeholder="Search transactions..."
value={filterState.searchTerm} value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)} onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8" className="pl-9 pr-8 bg-background"
/> />
{isSearchLoading && ( {isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2"> <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div> <div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div> </div>
)} )}
</div> </div>
@@ -117,7 +118,7 @@ export function FilterBar({
onClick={onClearFilters} onClick={onClearFilters}
variant="outline" variant="outline"
size="sm" size="sm"
className="text-gray-600" className="text-muted-foreground"
> >
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Clear All Clear All

View File

@@ -1,6 +1,6 @@
export { FilterBar } from './FilterBar'; export { FilterBar } from "./FilterBar";
export { DateRangePicker } from './DateRangePicker'; export { DateRangePicker } from "./DateRangePicker";
export { AccountCombobox } from './AccountCombobox'; export { AccountCombobox } from "./AccountCombobox";
export { ActiveFilterChips } from './ActiveFilterChips'; export { ActiveFilterChips } from "./ActiveFilterChips";
export { AdvancedFiltersPopover } from './AdvancedFiltersPopover'; export { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
export type { FilterState, FilterBarProps } from './FilterBar'; export type { FilterState, FilterBarProps } from "./FilterBar";

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
@@ -20,8 +20,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -31,27 +31,27 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react" } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({ function Calendar({
className, className,
@@ -19,9 +19,9 @@ function Calendar({
components, components,
...props ...props
}: React.ComponentProps<typeof DayPicker> & { }: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
return ( return (
<DayPicker <DayPicker
@@ -30,7 +30,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", "bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className className,
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
@@ -42,82 +42,82 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root), root: cn("w-fit", defaultClassNames.root),
months: cn( months: cn(
"relative flex flex-col gap-4 md:flex-row", "relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months defaultClassNames.months,
), ),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month), month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn( nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav defaultClassNames.nav,
), ),
button_previous: cn( button_previous: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous defaultClassNames.button_previous,
), ),
button_next: cn( button_next: cn(
buttonVariants({ variant: buttonVariant }), buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next defaultClassNames.button_next,
), ),
month_caption: cn( month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption defaultClassNames.month_caption,
), ),
dropdowns: cn( dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns defaultClassNames.dropdowns,
), ),
dropdown_root: cn( dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root defaultClassNames.dropdown_root,
), ),
dropdown: cn( dropdown: cn(
"bg-popover absolute inset-0 opacity-0", "bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown defaultClassNames.dropdown,
), ),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
? "text-sm" ? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday defaultClassNames.weekday,
), ),
week: cn("mt-2 flex w-full", defaultClassNames.week), week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn( week_number_header: cn(
"w-[--cell-size] select-none", "w-[--cell-size] select-none",
defaultClassNames.week_number_header defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
"text-muted-foreground select-none text-[0.8rem]", "text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number defaultClassNames.week_number,
), ),
day: cn( day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
"bg-accent rounded-l-md", "bg-accent rounded-l-md",
defaultClassNames.range_start defaultClassNames.range_start,
), ),
range_middle: cn("rounded-none", defaultClassNames.range_middle), range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn( today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today defaultClassNames.today,
), ),
outside: cn( outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground", "text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside defaultClassNames.outside,
), ),
disabled: cn( disabled: cn(
"text-muted-foreground opacity-50", "text-muted-foreground opacity-50",
defaultClassNames.disabled defaultClassNames.disabled,
), ),
hidden: cn("invisible", defaultClassNames.hidden), hidden: cn("invisible", defaultClassNames.hidden),
...classNames, ...classNames,
@@ -131,13 +131,13 @@ function Calendar({
className={cn(className)} className={cn(className)}
{...props} {...props}
/> />
) );
}, },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} /> <ChevronLeftIcon className={cn("size-4", className)} {...props} />
) );
} }
if (orientation === "right") { if (orientation === "right") {
@@ -146,12 +146,12 @@ function Calendar({
className={cn("size-4", className)} className={cn("size-4", className)}
{...props} {...props}
/> />
) );
} }
return ( return (
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <ChevronDownIcon className={cn("size-4", className)} {...props} />
) );
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
@@ -161,13 +161,13 @@ function Calendar({
{children} {children}
</div> </div>
</td> </td>
) );
}, },
...components, ...components,
}} }}
{...props} {...props}
/> />
) );
} }
function CalendarDayButton({ function CalendarDayButton({
@@ -176,12 +176,12 @@ function CalendarDayButton({
modifiers, modifiers,
...props ...props
}: React.ComponentProps<typeof DayButton>) { }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (modifiers.focused) ref.current?.focus() if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]) }, [modifiers.focused]);
return ( return (
<Button <Button
@@ -201,11 +201,11 @@ function CalendarDayButton({
className={cn( className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70", "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day, defaultClassNames.day,
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Calendar, CalendarDayButton } export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -1,12 +1,12 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog" import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react" import { Search } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef< const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>, React.ElementRef<typeof CommandPrimitive>,
@@ -16,12 +16,12 @@ const Command = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Command.displayName = CommandPrimitive.displayName Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => { const CommandDialog = ({ children, ...props }: DialogProps) => {
return ( return (
@@ -32,8 +32,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} };
const CommandInput = React.forwardRef< const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>, React.ElementRef<typeof CommandPrimitive.Input>,
@@ -45,14 +45,14 @@ const CommandInput = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
)) ));
CommandInput.displayName = CommandPrimitive.Input.displayName CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef< const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>, React.ElementRef<typeof CommandPrimitive.List>,
@@ -63,9 +63,9 @@ const CommandList = React.forwardRef<
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} {...props}
/> />
)) ));
CommandList.displayName = CommandPrimitive.List.displayName CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef< const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>, React.ElementRef<typeof CommandPrimitive.Empty>,
@@ -76,9 +76,9 @@ const CommandEmpty = React.forwardRef<
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
)) ));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef< const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>, React.ElementRef<typeof CommandPrimitive.Group>,
@@ -88,13 +88,13 @@ const CommandGroup = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
)) ));
CommandGroup.displayName = CommandPrimitive.Group.displayName CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef< const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>, React.ElementRef<typeof CommandPrimitive.Separator>,
@@ -105,8 +105,8 @@ const CommandSeparator = React.forwardRef<
className={cn("-mx-1 h-px bg-border", className)} className={cn("-mx-1 h-px bg-border", className)}
{...props} {...props}
/> />
)) ));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef< const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>, React.ElementRef<typeof CommandPrimitive.Item>,
@@ -116,13 +116,13 @@ const CommandItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
CommandItem.displayName = CommandPrimitive.Item.displayName CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ const CommandShortcut = ({
className, className,
@@ -132,13 +132,13 @@ const CommandShortcut = ({
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", "ml-auto text-xs tracking-widest text-muted-foreground",
className className,
)} )}
{...props} {...props}
/> />
) );
} };
CommandShortcut.displayName = "CommandShortcut" CommandShortcut.displayName = "CommandShortcut";
export { export {
Command, Command,
@@ -150,4 +150,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
} };

View File

@@ -0,0 +1,137 @@
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface DataTablePaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
total: number;
hasNext: boolean;
hasPrev: boolean;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
export function DataTablePagination({
currentPage,
totalPages,
pageSize,
total,
hasNext,
hasPrev,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
return (
<div className="flex items-center justify-between px-2 py-4">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-foreground">Rows per page</p>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
onPageSizeChange(Number(value));
onPageChange(1); // Reset to first page when changing page size
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium text-foreground">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrev}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNext}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
Showing {(currentPage - 1) * pageSize + 1} to{" "}
{Math.min(currentPage * pageSize, total)} of {total} entries
</div>
</div>
{/* Mobile view */}
<div className="flex w-full items-center justify-between space-x-4 sm:hidden">
<div className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrev}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNext}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,16 @@
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react" 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< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -20,12 +20,12 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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} {...props}
> >
@@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@@ -58,12 +58,12 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@@ -72,12 +72,12 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@@ -87,12 +87,12 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@@ -103,8 +103,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@@ -117,4 +117,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

View File

@@ -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<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@@ -9,14 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type} type={type}
className={cn( 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", "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} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = "Input";
export { Input } export { Input };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View File

@@ -0,0 +1,118 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import type { ButtonProps } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
@@ -20,12 +20,12 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]", "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)) ));
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,14 +1,14 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react" import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className,
)} )}
{...props} {...props}
> >
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
@@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)} )}
> >
{children} {children}
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
@@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
className={cn("px-2 py-1.5 text-sm font-semibold", className)} className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -128,8 +128,8 @@ const SelectItem = React.forwardRef<
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -140,8 +140,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
@@ -154,4 +154,4 @@ export {
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} };

View File

@@ -0,0 +1,117 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -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 <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
case "system":
return <Monitor className="h-4 w-4" />;
}
};
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 (
<Button
variant="outline"
size="icon"
onClick={cycleTheme}
className="h-8 w-8"
title={getLabel()}
>
{getIcon()}
<span className="sr-only">{getLabel()}</span>
</Button>
);
}

View File

@@ -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<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
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 (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -28,7 +28,7 @@
--chart-3: 197 37% 24%; --chart-3: 197 37% 24%;
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem --radius: 0.5rem;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
@@ -54,7 +54,7 @@
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55% --chart-5: 340 75% 55%;
} }
} }

View File

@@ -56,13 +56,16 @@ export const apiClient = {
}, },
// Get historical balances for balance progression chart // Get historical balances for balance progression chart
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => { getHistoricalBalances: async (
days?: number,
accountId?: string,
): Promise<Balance[]> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId); if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>( const response = await api.get<ApiResponse<Balance[]>>(
`/balances/history?${queryParams.toString()}` `/balances/history?${queryParams.toString()}`,
); );
return response.data.data; return response.data.data;
}, },
@@ -171,40 +174,48 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>( const response = await api.get<ApiResponse<TransactionStats>>(
`/transactions/stats?${queryParams.toString()}` `/transactions/stats?${queryParams.toString()}`,
); );
return response.data.data; return response.data.data;
}, },
// Get all transactions for analytics (no pagination) // Get all transactions for analytics (no pagination)
getTransactionsForAnalytics: async (days?: number): Promise<AnalyticsTransaction[]> => { getTransactionsForAnalytics: async (
days?: number,
): Promise<AnalyticsTransaction[]> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>( const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
`/transactions/analytics?${queryParams.toString()}` `/transactions/analytics?${queryParams.toString()}`,
); );
return response.data.data; return response.data.data;
}, },
// Get monthly transaction statistics (pre-calculated) // Get monthly transaction statistics (pre-calculated)
getMonthlyTransactionStats: async (days?: number): Promise<Array<{ getMonthlyTransactionStats: async (
days?: number,
): Promise<
Array<{
month: string; month: string;
income: number; income: number;
expenses: number; expenses: number;
net: number; net: number;
}>> => { }>
> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString()); if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<Array<{ const response = await api.get<
ApiResponse<
Array<{
month: string; month: string;
income: number; income: number;
expenses: number; expenses: number;
net: number; net: number;
}>>>( }>
`/transactions/monthly-stats?${queryParams.toString()}` >
); >(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data; return response.data.data;
}, },
}; };

View File

@@ -1,11 +1,14 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { 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", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import "./index.css"; import "./index.css";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
@@ -19,7 +20,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -7,13 +7,13 @@ function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex h-screen bg-gray-100"> <div className="flex h-screen bg-background">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} /> <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */} {/* Mobile overlay */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden" className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
)} )}

View File

@@ -14,13 +14,14 @@ import BalanceChart from "../components/analytics/BalanceChart";
import TransactionDistribution from "../components/analytics/TransactionDistribution"; import TransactionDistribution from "../components/analytics/TransactionDistribution";
import MonthlyTrends from "../components/analytics/MonthlyTrends"; import MonthlyTrends from "../components/analytics/MonthlyTrends";
import TimePeriodFilter from "../components/analytics/TimePeriodFilter"; import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
import { Card, CardContent } from "../components/ui/card";
import type { TimePeriod } from "../lib/timePeriods"; import type { TimePeriod } from "../lib/timePeriods";
import { TIME_PERIODS } from "../lib/timePeriods"; import { TIME_PERIODS } from "../lib/timePeriods";
function AnalyticsDashboard() { function AnalyticsDashboard() {
// Default to Last 365 days // Default to Last 365 days
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>( const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3] TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3],
); );
// Fetch analytics data // Fetch analytics data
@@ -45,15 +46,15 @@ function AnalyticsDashboard() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48 mb-6"></div> <div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div> <div key={i} className="h-32 bg-muted rounded"></div>
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="h-96 bg-gray-200 rounded"></div> <div className="h-96 bg-muted rounded"></div>
<div className="h-96 bg-gray-200 rounded"></div> <div className="h-96 bg-muted rounded"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -63,11 +64,14 @@ function AnalyticsDashboard() {
return ( return (
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{/* Time Period Filter */} {/* Time Period Filter */}
<Card>
<CardContent className="p-4">
<TimePeriodFilter <TimePeriodFilter
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod} onPeriodChange={setSelectedPeriod}
className="bg-white rounded-lg shadow p-4 border border-gray-200"
/> />
</CardContent>
</Card>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -101,7 +105,9 @@ function AnalyticsDashboard() {
subtitle="Income minus expenses" subtitle="Income minus expenses"
icon={CreditCard} icon={CreditCard}
className={ className={
(stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200" (stats?.net_change || 0) >= 0
? "border-green-200"
: "border-red-200"
} }
/> />
<StatCard <StatCard
@@ -120,18 +126,24 @@ function AnalyticsDashboard() {
{/* Charts */} {/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="bg-white rounded-lg shadow p-6 border border-gray-200"> <Card>
<CardContent className="p-6">
<BalanceChart data={balances || []} accounts={accounts || []} /> <BalanceChart data={balances || []} accounts={accounts || []} />
</div> </CardContent>
<div className="bg-white rounded-lg shadow p-6 border border-gray-200"> </Card>
<Card>
<CardContent className="p-6">
<TransactionDistribution accounts={accounts || []} /> <TransactionDistribution accounts={accounts || []} />
</div> </CardContent>
</Card>
</div> </div>
{/* Monthly Trends */} {/* Monthly Trends */}
<div className="bg-white rounded-lg shadow p-6 border border-gray-200"> <Card>
<CardContent className="p-6">
<MonthlyTrends days={selectedPeriod.days} /> <MonthlyTrends days={selectedPeriod.days} />
</div> </CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -5,53 +5,53 @@ export default {
theme: { theme: {
extend: { extend: {
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: "var(--radius)",
md: 'calc(var(--radius) - 2px)', md: "calc(var(--radius) - 2px)",
sm: 'calc(var(--radius) - 4px)' sm: "calc(var(--radius) - 4px)",
}, },
colors: { colors: {
background: 'hsl(var(--background))', background: "hsl(var(--background))",
foreground: 'hsl(var(--foreground))', foreground: "hsl(var(--foreground))",
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--card-foreground))' foreground: "hsl(var(--card-foreground))",
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--popover-foreground))' foreground: "hsl(var(--popover-foreground))",
}, },
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--primary-foreground))' foreground: "hsl(var(--primary-foreground))",
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--secondary-foreground))' foreground: "hsl(var(--secondary-foreground))",
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--muted-foreground))' foreground: "hsl(var(--muted-foreground))",
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--accent-foreground))' foreground: "hsl(var(--accent-foreground))",
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--destructive))",
foreground: 'hsl(var(--destructive-foreground))' foreground: "hsl(var(--destructive-foreground))",
}, },
border: 'hsl(var(--border))', border: "hsl(var(--border))",
input: 'hsl(var(--input))', input: "hsl(var(--input))",
ring: 'hsl(var(--ring))', ring: "hsl(var(--ring))",
chart: { chart: {
'1': 'hsl(var(--chart-1))', 1: "hsl(var(--chart-1))",
'2': 'hsl(var(--chart-2))', 2: "hsl(var(--chart-2))",
'3': 'hsl(var(--chart-3))', 3: "hsl(var(--chart-3))",
'4': 'hsl(var(--chart-4))', 4: "hsl(var(--chart-4))",
'5': 'hsl(var(--chart-5))' 5: "hsl(var(--chart-5))",
} },
} },
} },
}, },
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")], plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
}; };