Compare commits

..

11 Commits

Author SHA1 Message Date
Elisiário Couto
ff9bccc0e9 chore(ci): Bump version to 2025.9.18 2025-09-19 11:25:10 +01:00
Elisiário Couto
83bb3fcef2 docs: Add instructions for shadcn/ui. 2025-09-19 11:24:49 +01:00
Elisiário Couto
fbb9e33279 feat(frontend): Transform layout to use shadcn dashboard-01 with iOS PWA safe area support.
- Replace custom layout with modern SidebarProvider/SidebarInset structure
- Add AppSidebar component using shadcn patterns with preserved account summary
- Add SiteHeader component with SidebarTrigger integration
- Install shadcn sidebar, separator, and related UI components
- Fix iOS PWA safe area issues - sidebar no longer overlaps dynamic island/notch
- Add pt-safe-top and pl-safe-left classes to sidebar and header components
- Remove legacy Sidebar.tsx and Header.tsx components
- Preserve all existing functionality: navigation, API health status, theme toggle, PWA features
- Improve mobile responsive behavior with built-in shadcn drawer patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 11:17:24 +01:00
Elisiário Couto
8228974c0c chore(ci): Bump version to 2025.9.17 2025-09-18 23:45:10 +01:00
Elisiário Couto
848eccb35b chore: Format files. 2025-09-18 23:43:08 +01:00
Elisiário Couto
25747d7d37 fix(api): Prevent duplicate notifications for existing transactions during sync.
The notification system was incorrectly sending notifications for existing
transactions that were being updated during sync operations. This change
modifies the transaction persistence logic to only return genuinely new
transactions, preventing duplicate notifications while maintaining data
integrity through INSERT OR REPLACE.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:42:11 +01:00
Elisiário Couto
b7d6cf8128 chore(ci): Bump version to 2025.9.16 2025-09-18 23:29:53 +01:00
copilot-swe-agent[bot]
6589c2dd66 fix(frontend): Add iOS safe area support for PWA sticky header
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-18 23:28:49 +01:00
Elisiário Couto
571072f6ac chore(ci): Bump version to 2025.9.15 2025-09-18 23:03:01 +01:00
Elisiário Couto
be4f7f8cec refactor(frontend): Simplify filter bar UI and remove advanced filters popover.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:01:51 +01:00
Elisiário Couto
056c33b9c5 feat(frontend): Add settings page with account management functionality.
Added comprehensive settings page with account settings component, integrated with existing layout and routing
structure. Updated project documentation with frontend architecture details.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>")
2025-09-18 22:19:36 +01:00
41 changed files with 5141 additions and 937 deletions

View File

@@ -143,19 +143,19 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install git-cliff - name: Install git-cliff
run: | run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/ sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes - name: Generate release notes
id: release_notes id: release_notes
run: | run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:

View File

@@ -81,10 +81,34 @@ The command outputs instructions for setting the required environment variable t
- **Naming**: PascalCase for components, camelCase for variables/functions - **Naming**: PascalCase for components, camelCase for variables/functions
- **Types**: Use `import type` for type-only imports, define interfaces/types - **Types**: Use `import type` for type-only imports, define interfaces/types
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes - **Styling**: Tailwind CSS with `clsx` utility for conditional classes
- **UI Components**: shadcn/ui components for consistent design system
- **Icons**: lucide-react with consistent naming - **Icons**: lucide-react with consistent naming
- **Data fetching**: @tanstack/react-query with proper error handling - **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing - **Components**: Functional components with hooks, proper TypeScript typing
## Frontend Structure
### Layout Architecture
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
### Key Components Location
- **UI Components**: `frontend/src/components/ui/` - shadcn/ui components and reusable UI primitives
- **Feature Components**: `frontend/src/components/` - Main app components
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
- **API**: `frontend/src/lib/api.ts` - API client configuration
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
### Routing Structure
- `/` - Overview/Dashboard (TransactionsTable component)
- `/transactions` - Transactions page
- `/analytics` - Analytics page
- `/notifications` - Notifications page
- `/settings` - Settings page
### General ### General
- **Formatting**: ruff for Python, ESLint for TypeScript - **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing - **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
@@ -98,6 +122,16 @@ The command outputs instructions for setting the required environment variable t
- Avoid including specific numbers, counts, or data-dependent information that may become outdated - Avoid including specific numbers, counts, or data-dependent information that may become outdated
- **Security**: Never log sensitive data, use environment variables for secrets - **Security**: Never log sensitive data, use environment variables for secrets
## AI Development Support
### shadcn/ui Integration
This project uses shadcn/ui for consistent UI components. The MCP server is configured for AI agents to:
- Search and browse available shadcn/ui components
- View component implementation details and examples
- Generate proper installation commands for new components
Use the shadcn MCP tools when working with UI components to ensure consistency with the existing design system.
## Contributing Guidelines ## Contributing Guidelines
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including: This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:

View File

@@ -1,4 +1,98 @@
## 2025.9.18 (2025/09/19)
### Documentation
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.18 (2025/09/19)
### Documentation
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.14 (2025/09/18) ## 2025.9.14 (2025/09/18)
### Bug Fixes ### Bug Fixes

View File

@@ -1,9 +1,19 @@
# Contributing # Contributing
Install Poetry and run `poetry install` to install dependencies. Then run `poetry shell` to activate the virtual environment. This project uses **uv** for Python dependency management and **shadcn/ui** for frontend components.
## Setup
Install uv and run `uv sync` to install dependencies.
Run `pre-commit install` to install the pre-commit hooks. Run `pre-commit install` to install the pre-commit hooks.
## Frontend Development
The frontend uses shadcn/ui components for consistent design. When adding new UI components:
- Check if a shadcn/ui component exists for your use case
- Follow the existing component patterns in `frontend/src/components/ui/`
- Use Tailwind CSS classes for styling
- Ensure components are accessible and follow the design system
## Commit messages ## Commit messages
type(scope/[subscope]): Title starting with uppercase and sentence ending with period. type(scope/[subscope]): Title starting with uppercase and sentence ending with period.

View File

@@ -20,6 +20,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
- [React](https://reactjs.org/): Modern web interface with TypeScript - [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server - [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework - [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React - [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
## ✨ Features ## ✨ Features

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.36", "@tanstack/react-router": "^1.131.36",
@@ -27,15 +41,18 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-day-picker": "^9.10.0", "react-day-picker": "^9.10.0",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"recharts": "^3.2.0", "recharts": "^2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
@@ -48,6 +65,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"shadcn": "^3.3.1",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",

View File

@@ -6,4 +6,4 @@
<TileColor>#3B82F6</TileColor> <TileColor>#3B82F6</TileColor>
</tile> </tile>
</msapplication> </msapplication>
</browserconfig> </browserconfig>

View File

@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: /sitemap.xml Sitemap: /sitemap.xml

View File

@@ -1,4 +1,4 @@
{ {
"preset": "minimal-2023", "preset": "minimal-2023",
"images": ["public/favicon.svg"] "images": ["public/favicon.svg"]
} }

View File

@@ -0,0 +1,347 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Plus,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-amber-500",
tooltip: "Pending",
};
case "error":
case "failed":
return {
color: "bg-destructive",
tooltip: "Error",
};
case "inactive":
return {
color: "bg-muted-foreground",
tooltip: "Inactive",
};
default:
return {
color: "bg-primary",
tooltip: status,
};
}
};
export default function AccountSettings() {
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
});
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
if (accountsLoading) {
return <AccountsSkeleton />;
}
if (accountsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load accounts</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Account Management Section */}
<Card>
<CardHeader>
<CardTitle>Account Management</CardTitle>
<CardDescription>
Manage your connected bank accounts and customize their display names
</CardDescription>
</CardHeader>
{!accounts || accounts.length === 0 ? (
<CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No accounts found
</h3>
<p className="text-muted-foreground mb-4">
Connect your first bank account to get started with Leggen.
</p>
<Button disabled className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Bank Account</span>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Coming soon: Add new bank connections
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
{/* 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 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-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) =>
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="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Add Bank Section (Future Feature) */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in one place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for updates!
</p>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import {
List,
BarChart3,
Bell,
Settings,
Building2,
TrendingUp,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
} from "./ui/sidebar";
const navigation = [
{ name: "Overview", icon: List, to: "/" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" },
{ name: "Settings", icon: Settings, to: "/settings" },
];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const location = useLocation();
const [accountsExpanded, setAccountsExpanded] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const totalBalance =
accounts?.reduce((sum, account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<Link to="/" className="flex items-center space-x-2">
<Logo size={24} />
<span className="text-base font-semibold">Leggen</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
asChild
tooltip={item.name}
isActive={location.pathname === item.to}
>
<Link to={item.to}>
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
{/* Account Summary Section */}
<SidebarGroup>
<SidebarGroupLabel>Account Summary</SidebarGroupLabel>
<div className="bg-muted rounded-lg p-1">
{/* Collapsible Header */}
<button
onClick={() => setAccountsExpanded(!accountsExpanded)}
className="w-full p-3 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
{accountsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="px-3 pb-2">
<p className="text-xl font-bold text-foreground">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts
</p>
</div>
{/* Expanded Account Details */}
{accountsExpanded && accounts && accounts.length > 0 && (
<div className="border-t border-border/50 max-h-48 overflow-y-auto">
{accounts.map((account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
return (
<div
key={account.id}
className="p-2 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start space-x-2">
<div className="flex-shrink-0 p-1 bg-background rounded">
<Building2 className="h-3 w-3 text-muted-foreground" />
</div>
<div className="space-y-1 min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{account.display_name || account.name || "Unnamed Account"}
</p>
<p className="text-xs font-semibold text-foreground">
{formatCurrency(primaryBalance, currency)}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</SidebarGroup>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -33,11 +33,11 @@ export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
try { try {
await deferredPrompt.prompt(); await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice; const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") { if (outcome === "accepted") {
onInstall?.(); onInstall?.();
} }
setDeferredPrompt(null); setDeferredPrompt(null);
setShowPrompt(false); setShowPrompt(false);
} catch (error) { } catch (error) {
@@ -153,4 +153,4 @@ export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptPr
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,107 +0,0 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
Home,
List,
BarChart3,
Bell,
TrendingUp,
X,
} from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { cn } from "../lib/utils";
import type { Account } from "../types/api";
const navigation = [
{ name: "Overview", icon: Home, to: "/" },
{ name: "Transactions", icon: List, to: "/transactions" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" },
];
interface SidebarProps {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
}
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
const location = useLocation();
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const totalBalance =
accounts?.reduce((sum, account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div
className={cn(
"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",
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
<Link
to="/"
onClick={() => setSidebarOpen(false)}
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
>
<Logo size={32} />
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="px-6 py-4">
<div className="space-y-1">
{navigation.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
location.pathname === item.to
? "bg-primary text-primary-foreground"
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
))}
</div>
</nav>
{/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-border mt-auto">
<div className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<p className="text-2xl font-bold text-foreground mt-1">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{accounts?.length || 0} accounts
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +1,20 @@
import { useLocation } from "@tanstack/react-router"; import { useLocation } from "@tanstack/react-router";
import { Menu, Activity, Wifi, WifiOff } from "lucide-react"; import { 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"; import { ThemeToggle } from "./ui/theme-toggle";
import { Separator } from "./ui/separator";
import { SidebarTrigger } from "./ui/sidebar";
const navigation = [ const navigation = [
{ name: "Overview", to: "/" }, { name: "Overview", to: "/" },
{ name: "Transactions", to: "/transactions" }, { name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" }, { name: "Analytics", to: "/analytics" },
{ name: "Notifications", to: "/notifications" }, { name: "Notifications", to: "/notifications" },
{ name: "Settings", to: "/settings" },
]; ];
interface HeaderProps { export function SiteHeader() {
setSidebarOpen: (open: boolean) => void;
}
export default function Header({ setSidebarOpen }: HeaderProps) {
const location = useLocation(); const location = useLocation();
const currentPage = const currentPage =
navigation.find((item) => item.to === location.pathname)?.name || navigation.find((item) => item.to === location.pathname)?.name ||
@@ -32,20 +31,18 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
}); });
return ( return (
<header className="bg-card shadow-sm border-b border-border"> <header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
<div className="flex items-center justify-between h-16 px-6"> <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<div className="flex items-center"> <SidebarTrigger className="-ml-1" />
<button <Separator
onClick={() => setSidebarOpen(true)} orientation="vertical"
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground" className="mx-2 data-[orientation=vertical]:h-4"
> />
<Menu className="h-6 w-6" /> <h1 className="text-lg font-semibold text-card-foreground">
</button> {currentPage}
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4"> </h1>
{currentPage}
</h2> <div className="ml-auto flex items-center space-x-3">
</div>
<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 ? (
<> <>

View File

@@ -28,7 +28,7 @@ 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 { DataTablePagination } from "./ui/data-table-pagination";
import { Card, CardContent } from "./ui/card"; import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api"; import type { Account, Transaction, ApiResponse } from "../types/api";
@@ -40,8 +40,6 @@ export default function TransactionsTable() {
selectedAccount: "", selectedAccount: "",
startDate: "", startDate: "",
endDate: "", endDate: "",
minAmount: "",
maxAmount: "",
}); });
const [showRawModal, setShowRawModal] = useState(false); const [showRawModal, setShowRawModal] = useState(false);
@@ -73,8 +71,6 @@ export default function TransactionsTable() {
selectedAccount: "", selectedAccount: "",
startDate: "", startDate: "",
endDate: "", endDate: "",
minAmount: "",
maxAmount: "",
}); });
setColumnFilters([]); setColumnFilters([]);
setCurrentPage(1); setCurrentPage(1);
@@ -116,8 +112,6 @@ export default function TransactionsTable() {
currentPage, currentPage,
perPage, perPage,
debouncedSearchTerm, debouncedSearchTerm,
filterState.minAmount,
filterState.maxAmount,
], ],
queryFn: () => queryFn: () =>
apiClient.getTransactions({ apiClient.getTransactions({
@@ -128,12 +122,6 @@ 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,
maxAmount: filterState.maxAmount
? parseFloat(filterState.maxAmount)
: undefined,
}), }),
}); });
@@ -157,8 +145,6 @@ export default function TransactionsTable() {
filterState.selectedAccount, filterState.selectedAccount,
filterState.startDate, filterState.startDate,
filterState.endDate, filterState.endDate,
filterState.minAmount,
filterState.maxAmount,
]); ]);
const handleViewRaw = (transaction: Transaction) => { const handleViewRaw = (transaction: Transaction) => {
@@ -175,9 +161,7 @@ export default function TransactionsTable() {
filterState.searchTerm || filterState.searchTerm ||
filterState.selectedAccount || filterState.selectedAccount ||
filterState.startDate || filterState.startDate ||
filterState.endDate || filterState.endDate;
filterState.minAmount ||
filterState.maxAmount;
// Define columns // Define columns
@@ -372,38 +356,6 @@ export default function TransactionsTable() {
isSearchLoading={isSearchLoading} isSearchLoading={isSearchLoading}
/> />
{/* Results Summary */}
<Card>
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<p className="text-sm text-muted-foreground">
Showing {transactions.length} transaction
{transactions.length !== 1 ? "s" : ""} (
{pagination ? (
<>
{(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}{" "}
of {pagination.total}
</>
) : (
"loading..."
)}
)
{filterState.selectedAccount && accounts && (
<span className="ml-1">
for{" "}
{
accounts.find((acc) => acc.id === filterState.selectedAccount)
?.name
}
</span>
)}
</p>
</CardContent>
</Card>
{/* Responsive Table/Cards */} {/* Responsive Table/Cards */}
<Card> <Card>
{/* Desktop Table View (hidden on mobile) */} {/* Desktop Table View (hidden on mobile) */}

View File

@@ -51,7 +51,7 @@ export function AccountCombobox({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="justify-between" className="w-full justify-between"
> >
<div className="flex items-center"> <div className="flex items-center">
<Building2 className="mr-2 h-4 w-4" /> <Building2 className="mr-2 h-4 w-4" />

View File

@@ -8,12 +8,14 @@ import type { Account } from "../../types/api";
export interface ActiveFilterChipsProps { export interface ActiveFilterChipsProps {
filterState: FilterState; filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void; onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[]; accounts?: Account[];
} }
export function ActiveFilterChips({ export function ActiveFilterChips({
filterState, filterState,
onFilterChange, onFilterChange,
onClearFilters,
accounts = [], accounts = [],
}: ActiveFilterChipsProps) { }: ActiveFilterChipsProps) {
const chips: Array<{ const chips: Array<{
@@ -68,30 +70,6 @@ export function ActiveFilterChips({
}); });
} }
// Amount range chips
if (filterState.minAmount || filterState.maxAmount) {
let amountLabel = "Amount: ";
const minAmount = filterState.minAmount
? parseFloat(filterState.minAmount)
: null;
const maxAmount = filterState.maxAmount
? parseFloat(filterState.maxAmount)
: null;
if (minAmount && maxAmount) {
amountLabel += `${minAmount} - €${maxAmount}`;
} else if (minAmount) {
amountLabel += `≥ €${minAmount}`;
} else if (maxAmount) {
amountLabel += `≤ €${maxAmount}`;
}
chips.push({
key: "minAmount", // We'll clear both min and max when removing this chip
label: amountLabel,
value: `${filterState.minAmount}-${filterState.maxAmount}`,
});
}
const handleRemoveChip = (key: keyof FilterState) => { const handleRemoveChip = (key: keyof FilterState) => {
switch (key) { switch (key) {
@@ -100,11 +78,6 @@ export function ActiveFilterChips({
onFilterChange("startDate", ""); onFilterChange("startDate", "");
onFilterChange("endDate", ""); onFilterChange("endDate", "");
break; break;
case "minAmount":
// Clear both min and max amount
onFilterChange("minAmount", "");
onFilterChange("maxAmount", "");
break;
default: default:
onFilterChange(key, ""); onFilterChange(key, "");
} }
@@ -135,6 +108,15 @@ export function ActiveFilterChips({
</Button> </Button>
</Badge> </Badge>
))} ))}
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground ml-2"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
</div> </div>
); );
} }

View File

@@ -1,127 +0,0 @@
import { useState } from "react";
import { MoreHorizontal, Euro } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AdvancedFiltersPopoverProps {
minAmount: string;
maxAmount: string;
onMinAmountChange: (value: string) => void;
onMaxAmountChange: (value: string) => void;
}
export function AdvancedFiltersPopover({
minAmount,
maxAmount,
onMinAmountChange,
onMaxAmountChange,
}: AdvancedFiltersPopoverProps) {
const [open, setOpen] = useState(false);
const hasAdvancedFilters = minAmount || maxAmount;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={hasAdvancedFilters ? "default" : "outline"}
size="default"
className="relative"
>
<MoreHorizontal className="h-4 w-4 mr-2" />
More
{hasAdvancedFilters && (
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Advanced Filters</h4>
<p className="text-sm text-muted-foreground">
Additional filters for more precise results
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Amount Range
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Minimum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="0.00"
value={minAmount}
onChange={(e) => onMinAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Maximum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="1000.00"
value={maxAmount}
onChange={(e) => onMaxAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Leave empty for no limit
</p>
</div>
{/* Future: Add transaction status filter */}
<div className="pt-2 border-t">
<div className="text-xs text-muted-foreground">
More filters coming soon: transaction status, categories, and
more.
</div>
</div>
{/* Clear advanced filters */}
{hasAdvancedFilters && (
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
onMinAmountChange("");
onMaxAmountChange("");
}}
className="w-full"
>
Clear Advanced Filters
</Button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -6,6 +6,7 @@ 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 { Card, CardContent, CardFooter } from "@/components/ui/card";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -26,33 +27,35 @@ interface DatePreset {
const datePresets: DatePreset[] = [ const datePresets: DatePreset[] = [
{ {
label: "Last 7 days", label: "Today",
getValue: () => { getValue: () => {
const endDate = new Date(); const today = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
return { return {
startDate: startDate.toISOString().split("T")[0], startDate: today.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0], endDate: today.toISOString().split("T")[0],
}; };
}, },
}, },
{ {
label: "This week", label: "Yesterday",
getValue: () => { getValue: () => {
const now = new Date(); const yesterday = new Date();
const dayOfWeek = now.getDay(); yesterday.setDate(yesterday.getDate() - 1);
const startOfWeek = new Date(now);
startOfWeek.setDate(
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
); // Monday as start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
return { return {
startDate: startOfWeek.toISOString().split("T")[0], startDate: yesterday.toISOString().split("T")[0],
endDate: endOfWeek.toISOString().split("T")[0], endDate: yesterday.toISOString().split("T")[0],
};
},
},
{
label: "Last 7 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 6);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
}; };
}, },
}, },
@@ -61,7 +64,7 @@ const datePresets: DatePreset[] = [
getValue: () => { getValue: () => {
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(endDate.getDate() - 30); startDate.setDate(endDate.getDate() - 29);
return { return {
startDate: startDate.toISOString().split("T")[0], startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0], endDate: endDate.toISOString().split("T")[0],
@@ -81,19 +84,6 @@ const datePresets: DatePreset[] = [
}; };
}, },
}, },
{
label: "This year",
getValue: () => {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
return {
startDate: startOfYear.toISOString().split("T")[0],
endDate: endOfYear.toISOString().split("T")[0],
};
},
},
]; ];
export function DateRangePicker({ export function DateRangePicker({
@@ -178,34 +168,30 @@ export function DateRangePicker({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="flex"> <Card className="w-auto py-4">
{/* Presets */} <CardContent className="px-4">
<div className="border-r p-3 space-y-1"> <Calendar
<div className="text-sm font-medium text-gray-700 mb-2"> mode="range"
Quick select defaultMonth={dateRange?.from}
</div> selected={dateRange}
onSelect={handleDateRangeSelect}
className="bg-transparent p-0"
/>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-1 border-t px-4 !pt-4">
{datePresets.map((preset) => ( {datePresets.map((preset) => (
<Button <Button
key={preset.label} key={preset.label}
variant="ghost" variant="outline"
size="sm" size="sm"
className="w-full justify-start text-sm" className="text-xs px-2 h-7"
onClick={() => handlePresetClick(preset)} onClick={() => handlePresetClick(preset)}
> >
{preset.label} {preset.label}
</Button> </Button>
))} ))}
</div> </CardFooter>
{/* Calendar */} </Card>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
/>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>

View File

@@ -1,11 +1,9 @@
import { Search, X } from "lucide-react"; import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateRangePicker } from "./DateRangePicker"; import { DateRangePicker } from "./DateRangePicker";
import { AccountCombobox } from "./AccountCombobox"; import { AccountCombobox } from "./AccountCombobox";
import { ActiveFilterChips } from "./ActiveFilterChips"; import { ActiveFilterChips } from "./ActiveFilterChips";
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
import type { Account } from "../../types/api"; import type { Account } from "../../types/api";
export interface FilterState { export interface FilterState {
@@ -13,8 +11,6 @@ export interface FilterState {
selectedAccount: string; selectedAccount: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
minAmount: string;
maxAmount: string;
} }
export interface FilterBarProps { export interface FilterBarProps {
@@ -38,9 +34,7 @@ export function FilterBar({
filterState.searchTerm || filterState.searchTerm ||
filterState.selectedAccount || filterState.selectedAccount ||
filterState.startDate || filterState.startDate ||
filterState.endDate || filterState.endDate;
filterState.minAmount ||
filterState.maxAmount;
const handleDateRangeChange = (startDate: string, endDate: string) => { const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate); onFilterChange("startDate", startDate);
@@ -58,61 +52,85 @@ export function FilterBar({
</div> </div>
{/* Primary Filters Row */} {/* Primary Filters Row */}
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="space-y-4 mb-4">
{/* Search Input */} {/* Desktop Layout */}
<div className="relative flex-1 min-w-[240px]"> <div className="hidden lg:flex items-center justify-between gap-6">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> {/* Left Side: Main Filters */}
<Input <div className="flex items-center gap-3 flex-1">
placeholder="Search transactions..." {/* Search Input */}
value={filterState.searchTerm} <div className="relative w-[200px]">
onChange={(e) => onFilterChange("searchTerm", e.target.value)} <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
className="pl-9 pr-8 bg-background" <Input
/> placeholder="Search transactions..."
{isSearchLoading && ( value={filterState.searchTerm}
<div className="absolute right-3 top-1/2 transform -translate-y-1/2"> onChange={(e) => onFilterChange("searchTerm", e.target.value)}
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div> className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div> </div>
)}
{/* Account Selection */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[180px]"
/>
{/* Date Range Picker */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[220px]"
/>
</div>
</div> </div>
{/* Account Selection */} {/* Mobile Layout */}
<AccountCombobox <div className="lg:hidden space-y-3">
accounts={accounts} {/* First Row: Search Input (Full Width) */}
selectedAccount={filterState.selectedAccount} <div className="relative">
onAccountChange={(accountId) => <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
onFilterChange("selectedAccount", accountId) <Input
} placeholder="Search..."
className="w-[200px]" value={filterState.searchTerm}
/> onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background w-full"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
{/* Date Range Picker */} {/* Second Row: Account Selection (Full Width) */}
<DateRangePicker <AccountCombobox
startDate={filterState.startDate} accounts={accounts}
endDate={filterState.endDate} selectedAccount={filterState.selectedAccount}
onDateRangeChange={handleDateRangeChange} onAccountChange={(accountId) =>
className="w-[240px]" onFilterChange("selectedAccount", accountId)
/> }
className="w-full"
/>
{/* Advanced Filters Button */} {/* Third Row: Date Range */}
<AdvancedFiltersPopover <DateRangePicker
minAmount={filterState.minAmount} startDate={filterState.startDate}
maxAmount={filterState.maxAmount} endDate={filterState.endDate}
onMinAmountChange={(value) => onFilterChange("minAmount", value)} onDateRangeChange={handleDateRangeChange}
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)} className="w-full"
/> />
{/* Clear Filters Button */} </div>
{hasActiveFilters && (
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
)}
</div> </div>
{/* Active Filter Chips */} {/* Active Filter Chips */}
@@ -120,6 +138,7 @@ export function FilterBar({
<ActiveFilterChips <ActiveFilterChips
filterState={filterState} filterState={filterState}
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
onClearFilters={onClearFilters}
accounts={accounts} accounts={accounts}
/> />
)} )}

View File

@@ -2,5 +2,4 @@ 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 type { FilterState, FilterBarProps } from "./FilterBar"; export type { FilterState, FilterBarProps } from "./FilterBar";

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,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,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,17 +1,19 @@
import * as React from "react"; "use client"
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
const Sheet = SheetPrimitive.Root; import { cn } from "@/lib/utils"
const SheetTrigger = SheetPrimitive.Trigger; const Sheet = SheetPrimitive.Root
const SheetClose = SheetPrimitive.Close; const SheetTrigger = SheetPrimitive.Trigger
const SheetPortal = SheetPrimitive.Portal; const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -20,16 +22,16 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
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}
ref={ref} ref={ref}
/> />
)); ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{ {
variants: { variants: {
side: { side: {
@@ -38,14 +40,14 @@ const sheetVariants = cva(
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right: right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
}, },
}, },
defaultVariants: { defaultVariants: {
side: "right", side: "right",
}, },
}, }
); )
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@@ -62,15 +64,15 @@ const SheetContent = React.forwardRef<
className={cn(sheetVariants({ side }), className)} className={cn(sheetVariants({ side }), className)}
{...props} {...props}
> >
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
{children}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)); ))
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ const SheetHeader = ({
className, className,
@@ -79,12 +81,12 @@ const SheetHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className, className
)} )}
{...props} {...props}
/> />
); )
SheetHeader.displayName = "SheetHeader"; SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ const SheetFooter = ({
className, className,
@@ -93,12 +95,12 @@ const SheetFooter = ({
<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}
/> />
); )
SheetFooter.displayName = "SheetFooter"; SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@@ -109,8 +111,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)} className={cn("text-lg font-semibold text-foreground", className)}
{...props} {...props}
/> />
)); ))
SheetTitle.displayName = SheetPrimitive.Title.displayName; SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@@ -121,8 +123,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)); ))
SheetDescription.displayName = SheetPrimitive.Description.displayName; SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
@@ -135,4 +137,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
}; }

View File

@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
function Skeleton({ function Skeleton({
className, className,
@@ -6,10 +6,10 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("animate-pulse rounded-md bg-muted", className)} className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} {...props}
/> />
); )
} }
export { Skeleton }; export { Skeleton }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -34,4 +34,4 @@ export function usePWA(): PWAUpdate {
updateAvailable, updateAvailable,
updateSW, updateSW,
}; };
} }

View File

@@ -29,6 +29,20 @@
--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;
/* iOS Safe Area Support for PWA */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
@@ -55,6 +69,14 @@
--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%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
} }

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions' import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as AnalyticsRouteImport } from './routes/analytics' import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@@ -19,6 +20,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
path: '/transactions', path: '/transactions',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const NotificationsRoute = NotificationsRouteImport.update({ const NotificationsRoute = NotificationsRouteImport.update({
id: '/notifications', id: '/notifications',
path: '/notifications', path: '/notifications',
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -52,20 +60,33 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions' fullPaths:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/transactions' to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions' id:
| '__root__'
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute
TransactionsRoute: typeof TransactionsRoute TransactionsRoute: typeof TransactionsRoute
} }
@@ -78,6 +99,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransactionsRouteImport preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/notifications': { '/notifications': {
id: '/notifications' id: '/notifications'
path: '/notifications' path: '/notifications'
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute, AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute, NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute,
TransactionsRoute: TransactionsRoute, TransactionsRoute: TransactionsRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -1,12 +1,14 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"; import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react"; import { AppSidebar } from "../components/AppSidebar";
import Sidebar from "../components/Sidebar"; import { SiteHeader } from "../components/SiteHeader";
import Header from "../components/Header";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts"; import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA"; import { usePWA } from "../hooks/usePWA";
import {
SidebarInset,
SidebarProvider,
} from "../components/ui/sidebar";
function RootLayout() { function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { updateAvailable, updateSW } = usePWA(); const { updateAvailable, updateSW } = usePWA();
const handlePWAInstall = () => { const handlePWAInstall = () => {
@@ -23,23 +25,21 @@ function RootLayout() {
}; };
return ( return (
<div className="flex min-h-screen bg-background"> <SidebarProvider
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} /> style={
{
{/* Mobile overlay */} "--sidebar-width": "16rem",
{sidebarOpen && ( "--header-height": "4rem",
<div } as React.CSSProperties
className="fixed inset-0 z-40 bg-black/50 lg:hidden" }
onClick={() => setSidebarOpen(false)} >
/> <AppSidebar />
)} <SidebarInset>
<SiteHeader />
<div className="flex flex-col flex-1"> <main className="flex-1 p-6 min-w-0">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </SidebarInset>
{/* PWA Prompts */} {/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} /> <PWAInstallPrompt onInstall={handlePWAInstall} />
@@ -47,7 +47,7 @@ function RootLayout() {
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate} onUpdate={handlePWAUpdate}
/> />
</div> </SidebarProvider>
); );
} }

View File

@@ -1,6 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import AccountsOverview from "../components/AccountsOverview"; import TransactionsTable from "../components/TransactionsTable";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: AccountsOverview, component: TransactionsTable,
validateSearch: (search) => ({
accountId: search.accountId as string | undefined,
startDate: search.startDate as string | undefined,
endDate: search.endDate as string | undefined,
}),
}); });

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AccountSettings from "../components/AccountSettings";
export const Route = createFileRoute("/settings")({
component: AccountSettings,
});

View File

@@ -3,55 +3,71 @@ export default {
darkMode: ["class"], darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
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: { spacing: {
background: "hsl(var(--background))", 'safe-top': 'var(--safe-area-inset-top)',
foreground: "hsl(var(--foreground))", 'safe-bottom': 'var(--safe-area-inset-bottom)',
card: { 'safe-left': 'var(--safe-area-inset-left)',
DEFAULT: "hsl(var(--card))", 'safe-right': 'var(--safe-area-inset-right)'
foreground: "hsl(var(--card-foreground))", },
}, colors: {
popover: { background: 'hsl(var(--background))',
DEFAULT: "hsl(var(--popover))", foreground: 'hsl(var(--foreground))',
foreground: "hsl(var(--popover-foreground))", card: {
}, DEFAULT: 'hsl(var(--card))',
primary: { foreground: 'hsl(var(--card-foreground))'
DEFAULT: "hsl(var(--primary))", },
foreground: "hsl(var(--primary-foreground))", popover: {
}, DEFAULT: 'hsl(var(--popover))',
secondary: { foreground: 'hsl(var(--popover-foreground))'
DEFAULT: "hsl(var(--secondary))", },
foreground: "hsl(var(--secondary-foreground))", primary: {
}, DEFAULT: 'hsl(var(--primary))',
muted: { foreground: 'hsl(var(--primary-foreground))'
DEFAULT: "hsl(var(--muted))", },
foreground: "hsl(var(--muted-foreground))", secondary: {
}, DEFAULT: 'hsl(var(--secondary))',
accent: { foreground: 'hsl(var(--secondary-foreground))'
DEFAULT: "hsl(var(--accent))", },
foreground: "hsl(var(--accent-foreground))", muted: {
}, DEFAULT: 'hsl(var(--muted))',
destructive: { foreground: 'hsl(var(--muted-foreground))'
DEFAULT: "hsl(var(--destructive))", },
foreground: "hsl(var(--destructive-foreground))", accent: {
}, DEFAULT: 'hsl(var(--accent))',
border: "hsl(var(--border))", foreground: 'hsl(var(--accent-foreground))'
input: "hsl(var(--input))", },
ring: "hsl(var(--ring))", destructive: {
chart: { DEFAULT: 'hsl(var(--destructive))',
1: "hsl(var(--chart-1))", foreground: 'hsl(var(--destructive-foreground))'
2: "hsl(var(--chart-2))", },
3: "hsl(var(--chart-3))", border: 'hsl(var(--border))',
4: "hsl(var(--chart-4))", input: 'hsl(var(--input))',
5: "hsl(var(--chart-5))", ring: 'hsl(var(--ring))',
}, chart: {
}, '1': 'hsl(var(--chart-1))',
}, '2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
}
}
}, },
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")], plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
}; };

View File

@@ -693,7 +693,7 @@ class DatabaseService:
# Add the display_name column # Add the display_name column
cursor.execute(""" cursor.execute("""
ALTER TABLE accounts ALTER TABLE accounts
ADD COLUMN display_name TEXT ADD COLUMN display_name TEXT
""") """)
@@ -857,6 +857,14 @@ class DatabaseService:
for transaction in transactions: for transaction in transactions:
try: try:
# Check if transaction already exists before insertion
cursor.execute(
"""SELECT COUNT(*) FROM transactions
WHERE accountId = ? AND transactionId = ?""",
(transaction["accountId"], transaction["transactionId"]),
)
exists = cursor.fetchone()[0] > 0
cursor.execute( cursor.execute(
insert_sql, insert_sql,
( (
@@ -873,7 +881,11 @@ class DatabaseService:
json.dumps(transaction["rawTransaction"]), json.dumps(transaction["rawTransaction"]),
), ),
) )
new_transactions.append(transaction)
# Only add to new_transactions if it didn't exist before
if not exists:
new_transactions.append(transaction)
except sqlite3.IntegrityError as e: except sqlite3.IntegrityError as e:
logger.warning( logger.warning(
f"Failed to insert transaction {transaction.get('transactionId')}: {e}" f"Failed to insert transaction {transaction.get('transactionId')}: {e}"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.14" version = "2025.9.18"
description = "An Open Banking CLI" description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }] authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0" requires-python = "~=3.13.0"

View File

@@ -324,6 +324,8 @@ class TestDatabaseService:
with patch("sqlite3.connect") as mock_connect: with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
mock_cursor.fetchone.return_value = (0,)
result = await database_service._persist_transactions_sqlite( result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format "test-account-123", sample_transactions_db_format
@@ -338,6 +340,29 @@ class TestDatabaseService:
mock_conn.commit.assert_called_once() mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once() mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_duplicate_detection(
self, database_service, sample_transactions_db_format
):
"""Test that existing transactions are not returned as new."""
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (1,) indicating transaction already exists
mock_cursor.fetchone.return_value = (1,)
result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format
)
# Should return empty list since all transactions already exist
assert len(result) == 0
# Verify database operations still happened (INSERT OR REPLACE executed)
mock_connect.assert_called()
mock_cursor.execute.assert_called()
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_error(self, database_service): async def test_persist_transactions_sqlite_error(self, database_service):
"""Test handling error during transaction persistence.""" """Test handling error during transaction persistence."""
with patch("sqlite3.connect") as mock_connect: with patch("sqlite3.connect") as mock_connect:

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]] [[package]]
name = "leggen" name = "leggen"
version = "2025.9.14" version = "2025.9.18"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },