Compare commits
3 Commits
2025.9.13
...
4c953a5a60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c953a5a60 | ||
|
|
b1e562737b | ||
|
|
7521c77f82 |
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(uv sync:*)",
|
||||
"Bash(uv run pytest:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(ruff check:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(mypy:*)",
|
||||
"WebFetch(domain:localhost)",
|
||||
"Bash(npm create:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tailwindcss init:*)",
|
||||
"Bash(./node_modules/.bin/tailwindcss:*)",
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
@@ -164,4 +164,3 @@ sql/
|
||||
leggen.db
|
||||
*.db
|
||||
config.toml
|
||||
.claude/
|
||||
|
||||
17
.mcp.json
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"browsermcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@browsermcp/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
66
CHANGELOG.md
@@ -1,70 +1,4 @@
|
||||
|
||||
## 2025.9.13 (2025/09/17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
|
||||
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
|
||||
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
|
||||
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
|
||||
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
|
||||
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
|
||||
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
|
||||
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
|
||||
|
||||
|
||||
|
||||
## 2025.9.13 (2025/09/17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
|
||||
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
|
||||
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
|
||||
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
|
||||
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
|
||||
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
|
||||
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
|
||||
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
|
||||
|
||||
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.12 (2025/09/15)
|
||||
|
||||
|
||||
## 2025.9.11 (2025/09/15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
7
frontend/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(find:*)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist", "dev-dist"]),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Leggen</title>
|
||||
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="description" content="Personal finance management application" />
|
||||
<meta name="application-name" content="Leggen" />
|
||||
@@ -16,19 +16,19 @@
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#0b74de" />
|
||||
<meta name="msapplication-TileColor" content="#3B82F6" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
|
||||
<!-- Dynamic theme-color - will be updated by JavaScript -->
|
||||
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
|
||||
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" />
|
||||
<meta name="theme-color" content="#ffffff" id="theme-color-meta" />
|
||||
<meta name="msapplication-navbutton-color" content="#ffffff" id="ms-theme-color-meta" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-meta" />
|
||||
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#3B82F6" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
|
||||
690
frontend/package-lock.json
generated
@@ -12,7 +12,6 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
@@ -32,7 +31,6 @@
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"recharts": "^3.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 813 B After Width: | Height: | Size: 473 B |
@@ -1,27 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="32" height="32"
|
||||
viewBox="0 0 32 32"
|
||||
role="img" aria-labelledby="title desc">
|
||||
<title id="title">leggen — stylized italic L</title>
|
||||
<desc id="desc">Square gradient background with italic white L.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0b74de"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Square background -->
|
||||
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
|
||||
|
||||
<!-- Italic L -->
|
||||
<text x="11" y="22"
|
||||
font-family="Inter, Roboto, Arial, sans-serif"
|
||||
font-weight="700"
|
||||
font-size="20"
|
||||
font-style="italic"
|
||||
fill="#fff">
|
||||
L
|
||||
</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
||||
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 769 B After Width: | Height: | Size: 257 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 352 B |
@@ -22,7 +22,7 @@ import {
|
||||
} from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
// Helper function to get status indicator color and styles
|
||||
@@ -37,23 +37,23 @@ const getStatusIndicator = (status: string) => {
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
color: "bg-amber-500",
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Pending",
|
||||
};
|
||||
case "error":
|
||||
case "failed":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
color: "bg-red-500",
|
||||
tooltip: "Error",
|
||||
};
|
||||
case "inactive":
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
color: "bg-gray-500",
|
||||
tooltip: "Inactive",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-primary",
|
||||
color: "bg-blue-500",
|
||||
tooltip: status,
|
||||
};
|
||||
}
|
||||
@@ -81,8 +81,8 @@ export default function AccountsOverview() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||
apiClient.updateAccount(id, { display_name }),
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
apiClient.updateAccount(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setEditingAccountId(null);
|
||||
@@ -95,15 +95,14 @@ export default function AccountsOverview() {
|
||||
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
// Use display_name if available, otherwise fall back to name
|
||||
setEditingName(account.display_name || account.name || "");
|
||||
setEditingName(account.name || "");
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (editingAccountId && editingName.trim()) {
|
||||
updateAccountMutation.mutate({
|
||||
id: editingAccountId,
|
||||
display_name: editingName.trim(),
|
||||
name: editingName.trim(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -114,7 +113,11 @@ export default function AccountsOverview() {
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return <AccountsSkeleton />;
|
||||
return (
|
||||
<Card>
|
||||
<LoadingSpinner message="Loading accounts..." />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountsError) {
|
||||
@@ -197,8 +200,8 @@ export default function AccountsOverview() {
|
||||
{uniqueBanks}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -264,7 +267,7 @@ export default function AccountsOverview() {
|
||||
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"
|
||||
placeholder="Account name"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
@@ -300,9 +303,7 @@ export default function AccountsOverview() {
|
||||
<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"}
|
||||
{account.name || "Unnamed Account"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
|
||||
export default function AccountsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Accounts List Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="p-4 sm:p-6">
|
||||
<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">
|
||||
<Skeleton className="h-10 w-10 sm:h-12 sm:w-12 rounded-full flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Component } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -42,49 +39,46 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
An error occurred while rendering this component. Please try
|
||||
refreshing or check the console for more details.
|
||||
</p>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
An error occurred while rendering this component. Please try
|
||||
refreshing or check the console for more details.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<Alert variant="destructive" className="mb-4 text-left">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p className="text-sm font-mono">
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</p>
|
||||
{this.state.error.stack && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="text-xs mt-1 whitespace-pre-wrap">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{this.state.error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
|
||||
<p className="text-sm font-mono text-red-800">
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</p>
|
||||
{this.state.error.stack && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-red-600 cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={this.handleReset}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
|
||||
export default function FiltersSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<div className="bg-white rounded-lg shadow animate-pulse">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-6 py-4 border-b border-border bg-muted/30">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
{/* Quick Date Filters Skeleton */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-4 w-32 mb-3" />
|
||||
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||
<Skeleton className="h-10 w-28 rounded-lg" />
|
||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,40 +31,40 @@ export default function FiltersSkeleton() {
|
||||
{/* Filter Fields Skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="sm:col-span-2 lg:col-span-1">
|
||||
<Skeleton className="h-4 w-16 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Range Filters Skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-1" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* Results Summary Skeleton */}
|
||||
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,15 +49,15 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
||||
<div className="flex items-center space-x-1">
|
||||
{healthLoading ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4 text-muted-foreground animate-pulse" />
|
||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking...
|
||||
</span>
|
||||
</>
|
||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||
<>
|
||||
<WifiOff className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">Disconnected</span>
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-500">Disconnected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
18
frontend/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({
|
||||
message = "Loading...",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-gray-600 text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import NotificationsSkeleton from "./NotificationsSkeleton";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,7 +24,6 @@ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -81,7 +80,11 @@ export default function Notifications() {
|
||||
});
|
||||
|
||||
if (settingsLoading || servicesLoading) {
|
||||
return <NotificationsSkeleton />;
|
||||
return (
|
||||
<Card>
|
||||
<LoadingSpinner message="Loading notifications..." />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsError || servicesError) {
|
||||
@@ -230,10 +233,12 @@ export default function Notifications() {
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
variant={
|
||||
service.enabled ? "default" : "destructive"
|
||||
}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{service.enabled ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
@@ -241,16 +246,18 @@ export default function Notifications() {
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{service.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
service.configured ? "secondary" : "outline"
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.configured
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{service.configured
|
||||
? "Configured"
|
||||
: "Not Configured"}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
|
||||
export default function NotificationsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Test Notification Section Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-6 w-36" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Services Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="bg-muted rounded-md p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
CreditCard,
|
||||
Home,
|
||||
List,
|
||||
BarChart3,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
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";
|
||||
@@ -53,7 +53,7 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Logo size={32} />
|
||||
<CreditCard className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
||||
</Link>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
interface TransactionSkeletonProps {
|
||||
rows?: number;
|
||||
view?: "table" | "mobile";
|
||||
@@ -14,89 +11,93 @@ export default function TransactionSkeleton({
|
||||
|
||||
if (view === "mobile") {
|
||||
return (
|
||||
<Card className="divide-y divide-border">
|
||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||
{skeletonRows.map((_, index) => (
|
||||
<div key={index} className="p-4">
|
||||
<div key={index} className="p-4 animate-pulse">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
<Skeleton className="h-6 w-12 ml-auto" />
|
||||
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{skeletonRows.map((_, index) => (
|
||||
<tr key={index}>
|
||||
<tr key={index} className="animate-pulse">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-right">
|
||||
<Skeleton className="h-6 w-24 ml-auto mb-1" />
|
||||
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { DataTablePagination } from "./ui/data-table-pagination";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
||||
|
||||
export default function TransactionsTable() {
|
||||
// Filter state consolidated into a single object
|
||||
@@ -47,6 +47,7 @@ export default function TransactionsTable() {
|
||||
const [showRawModal, setShowRawModal] = useState(false);
|
||||
const [selectedTransaction, setSelectedTransaction] =
|
||||
useState<Transaction | null>(null);
|
||||
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -101,6 +102,11 @@ export default function TransactionsTable() {
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: apiClient.getBalances,
|
||||
enabled: showRunningBalance,
|
||||
});
|
||||
|
||||
const {
|
||||
data: transactionsResponse,
|
||||
@@ -179,6 +185,53 @@ export default function TransactionsTable() {
|
||||
filterState.minAmount ||
|
||||
filterState.maxAmount;
|
||||
|
||||
// Calculate running balances
|
||||
const calculateRunningBalances = (transactions: Transaction[]) => {
|
||||
if (!balances || !showRunningBalance) return {};
|
||||
|
||||
const runningBalances: { [key: string]: number } = {};
|
||||
const accountBalanceMap = new Map<string, number>();
|
||||
|
||||
// Create a map of account current balances
|
||||
balances.forEach((balance) => {
|
||||
if (balance.balance_type === "expected") {
|
||||
accountBalanceMap.set(balance.account_id, balance.balance_amount);
|
||||
}
|
||||
});
|
||||
|
||||
// Group transactions by account
|
||||
const transactionsByAccount = new Map<string, Transaction[]>();
|
||||
transactions.forEach((txn) => {
|
||||
if (!transactionsByAccount.has(txn.account_id)) {
|
||||
transactionsByAccount.set(txn.account_id, []);
|
||||
}
|
||||
transactionsByAccount.get(txn.account_id)!.push(txn);
|
||||
});
|
||||
|
||||
// Calculate running balance for each account
|
||||
transactionsByAccount.forEach((accountTransactions, accountId) => {
|
||||
const currentBalance = accountBalanceMap.get(accountId) || 0;
|
||||
let runningBalance = currentBalance;
|
||||
|
||||
// Sort transactions by date (newest first) to work backwards
|
||||
const sortedTransactions = [...accountTransactions].sort(
|
||||
(a, b) =>
|
||||
new Date(b.transaction_date).getTime() -
|
||||
new Date(a.transaction_date).getTime(),
|
||||
);
|
||||
|
||||
// Calculate running balance by working backwards from current balance
|
||||
sortedTransactions.forEach((txn) => {
|
||||
runningBalances[`${txn.account_id}-${txn.transaction_id}`] =
|
||||
runningBalance;
|
||||
runningBalance -= txn.transaction_value;
|
||||
});
|
||||
});
|
||||
|
||||
return runningBalances;
|
||||
};
|
||||
|
||||
const runningBalances = calculateRunningBalances(transactions);
|
||||
|
||||
// Define columns
|
||||
const columns: ColumnDef<Transaction>[] = [
|
||||
@@ -255,6 +308,29 @@ export default function TransactionsTable() {
|
||||
},
|
||||
sortingFn: "basic",
|
||||
},
|
||||
...(showRunningBalance
|
||||
? [
|
||||
{
|
||||
id: "running_balance",
|
||||
header: "Running Balance",
|
||||
cell: ({ row }: { row: { original: Transaction } }) => {
|
||||
const transaction = row.original;
|
||||
const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`;
|
||||
const balance = runningBalances[balanceKey];
|
||||
|
||||
if (balance === undefined) return null;
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{formatCurrency(balance, transaction.transaction_currency)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "transaction_date",
|
||||
header: "Date",
|
||||
@@ -362,7 +438,7 @@ export default function TransactionsTable() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-full">
|
||||
<div className="space-y-6">
|
||||
{/* New FilterBar */}
|
||||
<FilterBar
|
||||
filterState={filterState}
|
||||
@@ -370,6 +446,10 @@ export default function TransactionsTable() {
|
||||
onClearFilters={handleClearFilters}
|
||||
accounts={accounts}
|
||||
isSearchLoading={isSearchLoading}
|
||||
showRunningBalance={showRunningBalance}
|
||||
onToggleRunningBalance={() =>
|
||||
setShowRunningBalance(!showRunningBalance)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Results Summary */}
|
||||
@@ -405,91 +485,93 @@ export default function TransactionsTable() {
|
||||
</Card>
|
||||
|
||||
{/* Responsive Table/Cards */}
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Desktop Table View (hidden on mobile) */}
|
||||
<div className="hidden md:block">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
{header.column.getCanSort() && (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={`h-3 w-3 ${
|
||||
header.column.getIsSorted() === "asc"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 -mt-1 ${
|
||||
header.column.getIsSorted() === "desc"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-6 py-12 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground mb-4">
|
||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No transactions found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No transactions are available for the selected criteria."}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-muted/50">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className="px-6 py-4 whitespace-nowrap"
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
{header.column.getCanSort() && (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={`h-3 w-3 ${
|
||||
header.column.getIsSorted() === "asc"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 -mt-1 ${
|
||||
header.column.getIsSorted() === "desc"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-6 py-12 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground mb-4">
|
||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No transactions found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No transactions are available for the selected criteria."}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-muted/50">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className="px-6 py-4 whitespace-nowrap"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View (visible only on mobile) */}
|
||||
@@ -589,6 +671,17 @@ export default function TransactionsTable() {
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
{showRunningBalance && (
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Balance:{" "}
|
||||
{formatCurrency(
|
||||
runningBalances[
|
||||
`${transaction.account_id}-${transaction.transaction_id}`
|
||||
] || 0,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleViewRaw(transaction)}
|
||||
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
||||
|
||||
@@ -12,7 +12,6 @@ interface StatCardProps {
|
||||
isPositive: boolean;
|
||||
};
|
||||
className?: string;
|
||||
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
@@ -22,58 +21,43 @@ export default function StatCard({
|
||||
icon: Icon,
|
||||
trend,
|
||||
className,
|
||||
iconColor = "default",
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-2 flex items-baseline text-sm font-semibold",
|
||||
trend.isPositive
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"p-3 rounded-full",
|
||||
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
|
||||
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
|
||||
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
|
||||
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
|
||||
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
|
||||
iconColor === "default" && "bg-muted"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-6 w-6",
|
||||
iconColor === "green" && "text-green-600",
|
||||
iconColor === "blue" && "text-blue-600",
|
||||
iconColor === "red" && "text-red-600",
|
||||
iconColor === "purple" && "text-purple-600",
|
||||
iconColor === "orange" && "text-orange-600",
|
||||
iconColor === "default" && "text-muted-foreground"
|
||||
)} />
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-muted-foreground truncate">
|
||||
{title}
|
||||
</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-2 flex items-baseline text-sm font-semibold",
|
||||
trend.isPositive
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
{subtitle && (
|
||||
<dd className="text-sm text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -17,7 +17,6 @@ interface PieDataPoint {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
|
||||
@@ -38,8 +38,7 @@ export function AccountCombobox({
|
||||
);
|
||||
|
||||
const formatAccountName = (account: Account) => {
|
||||
const displayName =
|
||||
account.display_name || account.name || "Unnamed Account";
|
||||
const displayName = account.name || "Unnamed Account";
|
||||
return `${displayName} (${account.institution_id})`;
|
||||
};
|
||||
|
||||
@@ -90,7 +89,7 @@ export function AccountCombobox({
|
||||
{accounts.map((account) => (
|
||||
<CommandItem
|
||||
key={account.id}
|
||||
value={`${account.display_name || account.name} ${account.institution_id}`}
|
||||
value={`${account.name} ${account.institution_id}`}
|
||||
onSelect={() => {
|
||||
onAccountChange(account.id);
|
||||
setOpen(false);
|
||||
@@ -106,9 +105,7 @@ export function AccountCombobox({
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{account.display_name ||
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
{account.name || "Unnamed Account"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{account.institution_id}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface FilterBarProps {
|
||||
onClearFilters: () => void;
|
||||
accounts?: Account[];
|
||||
isSearchLoading?: boolean;
|
||||
showRunningBalance: boolean;
|
||||
onToggleRunningBalance: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -32,6 +34,8 @@ export function FilterBar({
|
||||
onClearFilters,
|
||||
accounts,
|
||||
isSearchLoading = false,
|
||||
showRunningBalance,
|
||||
onToggleRunningBalance,
|
||||
className,
|
||||
}: FilterBarProps) {
|
||||
const hasActiveFilters =
|
||||
@@ -55,6 +59,13 @@ export function FilterBar({
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
Transactions
|
||||
</h3>
|
||||
<Button
|
||||
onClick={onToggleRunningBalance}
|
||||
variant={showRunningBalance ? "default" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
Balance
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Primary Filters Row */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Logo({ className = "", size = 32 }: LogoProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-labelledby="logo-title logo-desc"
|
||||
>
|
||||
<title id="logo-title">leggen — stylized italic L</title>
|
||||
<desc id="logo-desc">Square gradient background with italic white L.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#0b74de" />
|
||||
<stop offset="100%" stopColor="#06b6d4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Square background */}
|
||||
<rect width="32" height="32" fill="url(#logo-bg)" rx="4" />
|
||||
|
||||
{/* Italic L */}
|
||||
<text
|
||||
x="11"
|
||||
y="22"
|
||||
fontFamily="Inter, Roboto, Arial, sans-serif"
|
||||
fontWeight="700"
|
||||
fontSize="20"
|
||||
fontStyle="italic"
|
||||
fill="#fff"
|
||||
>
|
||||
L
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -1,138 +0,0 @@
|
||||
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";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -12,7 +12,7 @@ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
// Theme colors for different modes
|
||||
const THEME_COLORS = {
|
||||
light: "#0b74de", // Primary brand color
|
||||
light: "#ffffff",
|
||||
dark: "#0f0f23", // Dark background color that matches typical dark themes
|
||||
} as const;
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Update theme-color meta tags for PWA status bar
|
||||
const themeColor = THEME_COLORS[resolvedTheme];
|
||||
|
||||
|
||||
// Update theme-color meta tag
|
||||
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
|
||||
if (themeColorMeta) {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 219 91% 46%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 189 94% 43%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
@@ -37,9 +37,9 @@
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 219 91% 46%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 189 94% 43%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
@@ -41,10 +41,11 @@ export const apiClient = {
|
||||
updateAccount: async (
|
||||
id: string,
|
||||
updates: AccountUpdate,
|
||||
): Promise<{ id: string; display_name?: string }> => {
|
||||
const response = await api.put<
|
||||
ApiResponse<{ id: string; display_name?: string }>
|
||||
>(`/accounts/${id}`, updates);
|
||||
): Promise<{ id: string; name?: string }> => {
|
||||
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
|
||||
`/accounts/${id}`,
|
||||
updates,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function RootLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
|
||||
{/* Mobile overlay */}
|
||||
@@ -34,18 +34,18 @@ function RootLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<Header setSidebarOpen={setSidebarOpen} />
|
||||
<main className="flex-1 p-6">
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* PWA Prompts */}
|
||||
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||
<PWAUpdatePrompt
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={handlePWAUpdate}
|
||||
<PWAUpdatePrompt
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={handlePWAUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,21 +80,20 @@ function AnalyticsDashboard() {
|
||||
value={stats?.total_transactions || 0}
|
||||
subtitle={`Last ${stats?.period_days || 0} days`}
|
||||
icon={Activity}
|
||||
iconColor="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Income"
|
||||
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
||||
subtitle="Inflows this period"
|
||||
icon={TrendingUp}
|
||||
iconColor="green"
|
||||
className="border-green-200"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Expenses"
|
||||
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
||||
subtitle="Outflows this period"
|
||||
icon={TrendingDown}
|
||||
iconColor="red"
|
||||
className="border-red-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,21 +104,23 @@ function AnalyticsDashboard() {
|
||||
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
||||
subtitle="Income minus expenses"
|
||||
icon={CreditCard}
|
||||
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
|
||||
className={
|
||||
(stats?.net_change || 0) >= 0
|
||||
? "border-green-200"
|
||||
: "border-red-200"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Transaction"
|
||||
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
||||
subtitle="Per transaction"
|
||||
icon={Activity}
|
||||
iconColor="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
value={stats?.accounts_included || 0}
|
||||
subtitle="With recent activity"
|
||||
icon={Users}
|
||||
iconColor="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface Account {
|
||||
status: string;
|
||||
iban?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
currency?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
@@ -19,7 +18,7 @@ export interface Account {
|
||||
}
|
||||
|
||||
export interface AccountUpdate {
|
||||
display_name?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface RawTransactionData {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
name: "Leggen",
|
||||
short_name: "Leggen",
|
||||
description: "Personal finance management application",
|
||||
theme_color: "#0b74de",
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
|
||||
@@ -24,7 +24,6 @@ class AccountDetails(BaseModel):
|
||||
status: str
|
||||
iban: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
@@ -37,7 +36,7 @@ class AccountDetails(BaseModel):
|
||||
class AccountUpdate(BaseModel):
|
||||
"""Account update model"""
|
||||
|
||||
display_name: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
@@ -53,7 +53,6 @@ async def get_all_accounts() -> APIResponse:
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
@@ -113,7 +112,6 @@ async def get_account_details(account_id: str) -> APIResponse:
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
@@ -326,7 +324,7 @@ async def get_account_transactions(
|
||||
async def update_account_details(
|
||||
account_id: str, update_data: AccountUpdate
|
||||
) -> APIResponse:
|
||||
"""Update account details (currently only display_name)"""
|
||||
"""Update account details (currently only name)"""
|
||||
try:
|
||||
# Get current account details
|
||||
current_account = await database_service.get_account_details_from_db(account_id)
|
||||
@@ -338,16 +336,16 @@ async def update_account_details(
|
||||
|
||||
# Prepare updated account data
|
||||
updated_account_data = current_account.copy()
|
||||
if update_data.display_name is not None:
|
||||
updated_account_data["display_name"] = update_data.display_name
|
||||
if update_data.name is not None:
|
||||
updated_account_data["name"] = update_data.name
|
||||
|
||||
# Persist updated account details
|
||||
await database_service.persist_account_details(updated_account_data)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"id": account_id, "display_name": update_data.display_name},
|
||||
message=f"Account {account_id} display name updated successfully",
|
||||
data={"id": account_id, "name": update_data.name},
|
||||
message=f"Account {account_id} name updated successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -215,7 +215,6 @@ class DatabaseService:
|
||||
await self._migrate_balance_timestamps_if_needed()
|
||||
await self._migrate_null_transaction_ids_if_needed()
|
||||
await self._migrate_to_composite_key_if_needed()
|
||||
await self._migrate_add_display_name_if_needed()
|
||||
|
||||
async def _migrate_balance_timestamps_if_needed(self):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
@@ -633,79 +632,6 @@ class DatabaseService:
|
||||
logger.error(f"Composite key migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _migrate_add_display_name_if_needed(self):
|
||||
"""Check and add display_name column to accounts table if needed"""
|
||||
try:
|
||||
if await self._check_display_name_migration_needed():
|
||||
logger.info("Display name column migration needed, starting...")
|
||||
await self._migrate_add_display_name()
|
||||
logger.info("Display name column migration completed")
|
||||
else:
|
||||
logger.info("Display name column already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Display name column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_display_name_migration_needed(self) -> bool:
|
||||
"""Check if display_name column needs to be added to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if accounts table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Check if display_name column exists
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
# Check if display_name column exists
|
||||
has_display_name = any(col[1] == "display_name" for col in columns)
|
||||
|
||||
conn.close()
|
||||
return not has_display_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check display_name migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_display_name(self):
|
||||
"""Add display_name column to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Adding display_name column to accounts table...")
|
||||
|
||||
# Add the display_name column
|
||||
cursor.execute("""
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN display_name TEXT
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Display name column migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Display name column migration failed: {e}")
|
||||
raise
|
||||
|
||||
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
||||
"""Convert Unix timestamp to datetime string"""
|
||||
dt = datetime.fromtimestamp(unix_timestamp)
|
||||
@@ -1119,8 +1045,7 @@ class DatabaseService:
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
last_updated DATETIME
|
||||
)"""
|
||||
)
|
||||
|
||||
@@ -1135,16 +1060,6 @@ class DatabaseService:
|
||||
)
|
||||
|
||||
try:
|
||||
# First, check if account exists and preserve display_name
|
||||
cursor.execute(
|
||||
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
|
||||
)
|
||||
existing_row = cursor.fetchone()
|
||||
existing_display_name = existing_row[0] if existing_row else None
|
||||
|
||||
# Use existing display_name if not provided in account_data
|
||||
display_name = account_data.get("display_name", existing_display_name)
|
||||
|
||||
# Insert or replace account data
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO accounts (
|
||||
@@ -1156,9 +1071,8 @@ class DatabaseService:
|
||||
currency,
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated,
|
||||
display_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_data["id"],
|
||||
account_data["institution_id"],
|
||||
@@ -1169,7 +1083,6 @@ class DatabaseService:
|
||||
account_data["created"],
|
||||
account_data.get("last_accessed"),
|
||||
account_data.get("last_updated", account_data["created"]),
|
||||
display_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.13"
|
||||
version = "2025.9.11"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -106,8 +106,7 @@ class SampleDataGenerator:
|
||||
currency TEXT,
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
last_updated DATETIME
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -374,8 +373,8 @@ class SampleDataGenerator:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO accounts
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account["id"],
|
||||
@@ -387,7 +386,6 @@ class SampleDataGenerator:
|
||||
account["created"],
|
||||
account["last_accessed"],
|
||||
account["last_updated"],
|
||||
None, # display_name is initially None for sample data
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ class TestAccountsAPI:
|
||||
"institution_id": "REVOLUT_REVOLT21",
|
||||
"status": "READY",
|
||||
"iban": "LT313250081177977789",
|
||||
"name": "Personal Account",
|
||||
"display_name": None,
|
||||
"created": "2024-02-13T23:56:00Z",
|
||||
"last_accessed": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
@@ -82,8 +80,6 @@ class TestAccountsAPI:
|
||||
"institution_id": "REVOLUT_REVOLT21",
|
||||
"status": "READY",
|
||||
"iban": "LT313250081177977789",
|
||||
"name": "Personal Account",
|
||||
"display_name": None,
|
||||
"created": "2024-02-13T23:56:00Z",
|
||||
"last_accessed": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
@@ -287,58 +283,3 @@ class TestAccountsAPI:
|
||||
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_account_display_name_success(
|
||||
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||
):
|
||||
"""Test successful update of account display name."""
|
||||
mock_account = {
|
||||
"id": "test-account-123",
|
||||
"institution_id": "REVOLUT_REVOLT21",
|
||||
"status": "READY",
|
||||
"iban": "LT313250081177977789",
|
||||
"name": "Personal Account",
|
||||
"display_name": None,
|
||||
"created": "2024-02-13T23:56:00Z",
|
||||
"last_accessed": "2025-09-01T09:30:00Z",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=mock_account,
|
||||
),
|
||||
patch(
|
||||
"leggen.api.routes.accounts.database_service.persist_account_details",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response = api_client.put(
|
||||
"/api/v1/accounts/test-account-123",
|
||||
json={"display_name": "My Custom Account Name"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["id"] == "test-account-123"
|
||||
assert data["data"]["display_name"] == "My Custom Account Name"
|
||||
|
||||
def test_update_account_not_found(
|
||||
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||
):
|
||||
"""Test updating non-existent account."""
|
||||
with (
|
||||
patch("leggen.utils.config.config", mock_config),
|
||||
patch(
|
||||
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
response = api_client.put(
|
||||
"/api/v1/accounts/nonexistent",
|
||||
json={"display_name": "New Name"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||