Compare commits

..

20 Commits

Author SHA1 Message Date
Elisiário Couto
02c4f5c6ef chore(ci): Bump version to 2025.9.14 2025-09-18 11:49:36 +01:00
Elisiário Couto
30d7c2ed4e chore(ci): Prevent double GitHub Actions runs on new releases. 2025-09-18 11:21:04 +01:00
Elisiário Couto
61442a598f fix(config): Remove aliases for configuration keys that were disabling telegram notifications in some cases. 2025-09-18 11:09:43 +01:00
Elisiário Couto
b7da446fa5 chore(ci): Bump version to 2025.9.13 2025-09-17 23:29:02 +01:00
Elisiário Couto
5a626b5394 chore: Enable browsermcp and shadcn MCP servers. 2025-09-17 23:27:14 +01:00
Elisiário Couto
d9a39c30ab feat(frontend): Update analytics cards to match home page design consistency.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:23:34 +01:00
Elisiário Couto
155a48d7dc fix(frontend): Remove broken running balance feature in transactions table. 2025-09-17 22:16:13 +01:00
Elisiário Couto
8ab760815c fix(frontend): Resolve dual scroll and excessive whitespace issues on transactions page.
- Change root layout from h-screen to min-h-screen to prevent height conflicts
- Remove overflow-hidden and overflow-y-auto from main container to eliminate competing scroll contexts
- Streamline TransactionsTable layout by removing unnecessary overflow wrappers
- Add max-w-full constraint to prevent horizontal overflow issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:11:03 +01:00
Elisiário Couto
2825dba2e9 feat(frontend): Update brand identity with new logo and color scheme.
- Add new Logo component with gradient design (blue #0b74de to cyan #06b6d4)
- Replace CreditCard icon with custom Logo in sidebar
- Update primary and secondary theme colors to match brand gradient
- Regenerate all PWA icons with new logo design
- Update theme colors in PWA manifest and meta tags
- Fix ESLint config to ignore generated PWA files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
3049a8cd2f feat(frontend): Add PWA install prompts, update notifications, and app shortcuts
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
86891441d6 feat(frontend): Add comprehensive PWA capabilities with dynamic theme support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
Elisiário Couto
81d7d16301 fix(frontend): Add index signature to PieDataPoint interface.
Resolves TypeScript error where PieDataPoint[] was not assignable to
ChartDataInput[] by adding the required string index signature.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 18:30:36 +01:00
Elisiário Couto
84e609a774 refactor(frontend): Replace LoadingSpinner with shadcn skeleton components.
- Created AccountsSkeleton.tsx and NotificationsSkeleton.tsx components
- Updated AccountsOverview.tsx and Notifications.tsx to use skeletons
- Removed unused LoadingSpinner.tsx component
- Improved loading state UX by showing content structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 18:30:36 +01:00
copilot-swe-agent[bot]
fb310a5953 fix(frontend): Resolve linting issue in skeleton component
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 18:30:36 +01:00
copilot-swe-agent[bot]
c83386b1d5 feat(frontend): Complete shadcn migration of skeleton and styling components
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 18:30:36 +01:00
Elisiário Couto
bfb5a7ef76 chore(ci): Bump version to 2025.9.12 2025-09-16 00:14:10 +01:00
copilot-swe-agent[bot]
95b3b93a8a Restore original package.json dev script with VITE_API_URL
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
Elisiário Couto
9a2199873c Delete frontend/.env.development 2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
82a12dadad Complete display_name feature with frontend integration and testing
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
33a7ad5ad2 Implement display_name field with migration and API support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
67 changed files with 6696 additions and 726 deletions

View File

@@ -1,22 +0,0 @@
{
"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": []
}
}

View File

@@ -10,6 +10,7 @@ jobs:
test-python: test-python:
name: Test Python name: Test Python
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -32,6 +33,7 @@ jobs:
test-frontend: test-frontend:
name: Test Frontend name: Test Frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
defaults: defaults:
run: run:
working-directory: ./frontend working-directory: ./frontend

1
.gitignore vendored
View File

@@ -164,3 +164,4 @@ sql/
leggen.db leggen.db
*.db *.db
config.toml config.toml
.claude/

17
.mcp.json Normal file
View File

@@ -0,0 +1,17 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
},
"browsermcp": {
"command": "npx",
"args": [
"@browsermcp/mcp@latest"
]
}
}
}

View File

@@ -1,4 +1,96 @@
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 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) ## 2025.9.11 (2025/09/15)
### Bug Fixes ### Bug Fixes

View File

@@ -146,8 +146,8 @@ enabled = true
# Optional: Transaction filters for notifications # Optional: Transaction filters for notifications
[filters] [filters]
case-insensitive = ["salary", "utility"] case_insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"] case_sensitive = ["SpecificStore"]
``` ```
## 📖 Usage ## 📖 Usage

View File

@@ -20,11 +20,11 @@ enabled = true
# Optional: Telegram notifications # Optional: Telegram notifications
[notifications.telegram] [notifications.telegram]
api-key = "your-bot-token" token = "your-bot-token"
chat-id = 12345 chat_id = 12345
enabled = true enabled = true
# Optional: Transaction filters for notifications # Optional: Transaction filters for notifications
[filters] [filters]
case-insensitive = ["salary", "utility"] case_insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"] case_sensitive = ["SpecificStore"]

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": ["Bash(find:*)"],
"deny": [],
"ask": []
}
}

1
frontend/.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
dev-dist
*.local *.local
# Editor directories and files # Editor directories and files

View File

@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config"; import { globalIgnores } from "eslint/config";
export default tseslint.config([ export default tseslint.config([
globalIgnores(["dist"]), globalIgnores(["dist", "dev-dist"]),
{ {
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
extends: [ extends: [

View File

@@ -2,9 +2,35 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" href="/favicon.ico" sizes="48x48" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Leggen</title>
<!-- PWA Meta Tags -->
<meta name="description" content="Personal finance management application" />
<meta name="application-name" content="Leggen" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Leggen" />
<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-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="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="shortcut icon" href="/favicon.ico" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
@@ -31,6 +32,7 @@
"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": "^3.2.0",
"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"
@@ -40,13 +42,16 @@
"@tanstack/router-vite-plugin": "^1.131.36", "@tanstack/router-vite-plugin": "^1.131.36",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vite-pwa/assets-generator": "^1.0.1",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"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",
"sharp": "^0.34.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",
"vite": "^7.1.2" "vite": "^7.1.2",
"vite-plugin-pwa": "^1.0.3"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/pwa-192x192.png"/>
<TileColor>#3B82F6</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -1,4 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"> <svg xmlns="http://www.w3.org/2000/svg"
<rect width="32" height="32" rx="6" fill="#3B82F6"/> width="32" height="32"
<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"/> 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> </svg>

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import {
} from "./ui/card"; } from "./ui/card";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import LoadingSpinner from "./LoadingSpinner"; import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api"; import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles // Helper function to get status indicator color and styles
@@ -37,23 +37,23 @@ const getStatusIndicator = (status: string) => {
}; };
case "pending": case "pending":
return { return {
color: "bg-yellow-500", color: "bg-amber-500",
tooltip: "Pending", tooltip: "Pending",
}; };
case "error": case "error":
case "failed": case "failed":
return { return {
color: "bg-red-500", color: "bg-destructive",
tooltip: "Error", tooltip: "Error",
}; };
case "inactive": case "inactive":
return { return {
color: "bg-gray-500", color: "bg-muted-foreground",
tooltip: "Inactive", tooltip: "Inactive",
}; };
default: default:
return { return {
color: "bg-blue-500", color: "bg-primary",
tooltip: status, tooltip: status,
}; };
} }
@@ -81,8 +81,8 @@ export default function AccountsOverview() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateAccountMutation = useMutation({ const updateAccountMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { name }), apiClient.updateAccount(id, { display_name }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] }); queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null); setEditingAccountId(null);
@@ -95,14 +95,15 @@ export default function AccountsOverview() {
const handleEditStart = (account: Account) => { const handleEditStart = (account: Account) => {
setEditingAccountId(account.id); setEditingAccountId(account.id);
setEditingName(account.name || ""); // Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
}; };
const handleEditSave = () => { const handleEditSave = () => {
if (editingAccountId && editingName.trim()) { if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({ updateAccountMutation.mutate({
id: editingAccountId, id: editingAccountId,
name: editingName.trim(), display_name: editingName.trim(),
}); });
} }
}; };
@@ -113,11 +114,7 @@ export default function AccountsOverview() {
}; };
if (accountsLoading) { if (accountsLoading) {
return ( return <AccountsSkeleton />;
<Card>
<LoadingSpinner message="Loading accounts..." />
</Card>
);
} }
if (accountsError) { if (accountsError) {
@@ -200,8 +197,8 @@ export default function AccountsOverview() {
{uniqueBanks} {uniqueBanks}
</p> </p>
</div> </div>
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full"> <div className="p-3 bg-muted rounded-full">
<Building2 className="h-6 w-6 text-purple-600" /> <Building2 className="h-6 w-6 text-muted-foreground" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -267,7 +264,7 @@ export default function AccountsOverview() {
setEditingName(e.target.value) 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" className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Account name" placeholder="Custom account name"
name="search" name="search"
autoComplete="off" autoComplete="off"
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -303,7 +300,9 @@ export default function AccountsOverview() {
<div> <div>
<div className="flex items-center space-x-2 min-w-0"> <div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate"> <h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.name || "Unnamed Account"} {account.display_name ||
account.name ||
"Unnamed Account"}
</h4> </h4>
<button <button
onClick={() => handleEditStart(account)} onClick={() => handleEditStart(account)}

View File

@@ -0,0 +1,61 @@
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>
);
}

View File

@@ -1,6 +1,9 @@
import { Component } from "react"; import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react"; import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-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 { interface Props {
children: ReactNode; children: ReactNode;
@@ -39,46 +42,49 @@ class ErrorBoundary extends Component<Props, State> {
} }
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-center"> <div className="flex items-center justify-center text-center">
<div> <div>
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" /> <AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
Something went wrong Something went wrong
</h3> </h3>
<p className="text-gray-600 mb-4"> <p className="text-muted-foreground mb-4">
An error occurred while rendering this component. Please try An error occurred while rendering this component. Please try
refreshing or check the console for more details. refreshing or check the console for more details.
</p> </p>
{this.state.error && ( {this.state.error && (
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left"> <Alert variant="destructive" className="mb-4 text-left">
<p className="text-sm font-mono text-red-800"> <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} <strong>Error:</strong> {this.state.error.message}
</p> </p>
{this.state.error.stack && ( {this.state.error.stack && (
<details className="mt-2"> <details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer"> <summary className="text-sm cursor-pointer">
Stack trace Stack trace
</summary> </summary>
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap"> <pre className="text-xs mt-1 whitespace-pre-wrap">
{this.state.error.stack} {this.state.error.stack}
</pre> </pre>
</details> </details>
)} )}
</div> </AlertDescription>
</Alert>
)} )}
<button <Button onClick={this.handleReset}>
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" /> <RefreshCw className="h-4 w-4 mr-2" />
Try Again Try Again
</button> </Button>
</div>
</div> </div>
</div> </div>
</CardContent>
</Card>
); );
} }

View File

@@ -1,29 +1,32 @@
import { Skeleton } from "./ui/skeleton";
import { Card, CardContent } from "./ui/card";
export default function FiltersSkeleton() { export default function FiltersSkeleton() {
return ( return (
<div className="bg-white rounded-lg shadow animate-pulse"> <Card>
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="h-6 bg-gray-200 rounded w-32"></div> <Skeleton className="h-6 w-32" />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="h-8 bg-gray-200 rounded w-24"></div> <Skeleton className="h-8 w-24" />
<div className="h-8 bg-gray-200 rounded w-20"></div> <Skeleton className="h-8 w-20" />
</div> </div>
</div> </div>
</div> </div>
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50"> <CardContent className="px-6 py-4 border-b border-border bg-muted/30">
{/* Quick Date Filters Skeleton */} {/* Quick Date Filters Skeleton */}
<div className="mb-6"> <div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div> <Skeleton className="h-4 w-32 mb-3" />
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div> <Skeleton className="h-10 w-24 rounded-lg" />
<div className="h-10 bg-gray-200 rounded-lg w-20"></div> <Skeleton className="h-10 w-20 rounded-lg" />
<div className="h-10 bg-gray-200 rounded-lg w-28"></div> <Skeleton className="h-10 w-28 rounded-lg" />
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div> <Skeleton className="h-10 w-24 rounded-lg" />
<div className="h-10 bg-gray-200 rounded-lg w-20"></div> <Skeleton className="h-10 w-20 rounded-lg" />
</div> </div>
</div> </div>
</div> </div>
@@ -31,40 +34,40 @@ export default function FiltersSkeleton() {
{/* Filter Fields Skeleton */} {/* Filter Fields Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <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"> <div className="sm:col-span-2 lg:col-span-1">
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div> <Skeleton className="h-4 w-16 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div> </div>
<div> <div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div> <Skeleton className="h-4 w-16 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div> </div>
<div> <div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div> <Skeleton className="h-4 w-20 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div> </div>
<div> <div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div> <Skeleton className="h-4 w-16 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div> </div>
</div> </div>
{/* Amount Range Filters Skeleton */} {/* Amount Range Filters Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div> <div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div> <Skeleton className="h-4 w-20 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div> </div>
<div> <div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div> <Skeleton className="h-4 w-20 mb-1" />
<div className="h-10 bg-gray-200 rounded"></div> <Skeleton className="h-10 w-full" />
</div>
</div> </div>
</div> </div>
</CardContent>
{/* Results Summary Skeleton */} {/* Results Summary Skeleton */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200"> <CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<div className="h-4 bg-gray-200 rounded w-48"></div> <Skeleton className="h-4 w-48" />
</div> </CardContent>
</div> </Card>
); );
} }

View File

@@ -49,15 +49,15 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{healthLoading ? ( {healthLoading ? (
<> <>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" /> <Activity className="h-4 w-4 text-muted-foreground animate-pulse" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Checking... Checking...
</span> </span>
</> </>
) : healthError || healthStatus?.status !== "healthy" ? ( ) : healthError || healthStatus?.status !== "healthy" ? (
<> <>
<WifiOff className="h-4 w-4 text-red-500" /> <WifiOff className="h-4 w-4 text-destructive" />
<span className="text-sm text-red-500">Disconnected</span> <span className="text-sm text-destructive">Disconnected</span>
</> </>
) : ( ) : (
<> <>

View File

@@ -1,18 +0,0 @@
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>
);
}

View File

@@ -12,7 +12,7 @@ import {
TestTube, TestTube,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import LoadingSpinner from "./LoadingSpinner"; import NotificationsSkeleton from "./NotificationsSkeleton";
import { import {
Card, Card,
CardContent, CardContent,
@@ -24,6 +24,7 @@ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
import { Badge } from "./ui/badge";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -80,11 +81,7 @@ export default function Notifications() {
}); });
if (settingsLoading || servicesLoading) { if (settingsLoading || servicesLoading) {
return ( return <NotificationsSkeleton />;
<Card>
<LoadingSpinner message="Loading notifications..." />
</Card>
);
} }
if (settingsError || servicesError) { if (settingsError || servicesError) {
@@ -233,12 +230,10 @@ export default function Notifications() {
{service.name} {service.name}
</h4> </h4>
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
<span <Badge
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ variant={
service.enabled service.enabled ? "default" : "destructive"
? "bg-green-100 text-green-800" }
: "bg-red-100 text-red-800"
}`}
> >
{service.enabled ? ( {service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" /> <CheckCircle className="h-3 w-3 mr-1" />
@@ -246,18 +241,16 @@ export default function Notifications() {
<AlertCircle className="h-3 w-3 mr-1" /> <AlertCircle className="h-3 w-3 mr-1" />
)} )}
{service.enabled ? "Enabled" : "Disabled"} {service.enabled ? "Enabled" : "Disabled"}
</span> </Badge>
<span <Badge
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ variant={
service.configured service.configured ? "secondary" : "outline"
? "bg-blue-100 text-blue-800" }
: "bg-yellow-100 text-yellow-800"
}`}
> >
{service.configured {service.configured
? "Configured" ? "Configured"
: "Not Configured"} : "Not Configured"}
</span> </Badge>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,95 @@
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>
);
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { X, Download, RotateCcw } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
interface PWAPromptProps {
onInstall?: () => void;
}
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
onInstall?.();
}
setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
console.error("Error installing PWA:", error);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDeferredPrompt(null);
};
if (!showPrompt || !deferredPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Install Leggen
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add to your home screen for quick access
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Install
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Not now
</button>
</div>
</div>
);
}
interface PWAUpdatePromptProps {
updateAvailable: boolean;
onUpdate: () => void;
}
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (updateAvailable) {
setShowPrompt(true);
}
}, [updateAvailable]);
const handleUpdate = () => {
onUpdate();
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !updateAvailable) return null;
return (
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Available
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
A new version of Leggen is ready to install
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Later
</button>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from "@tanstack/react-router";
import { import {
CreditCard,
Home, Home,
List, List,
BarChart3, BarChart3,
@@ -8,6 +7,7 @@ import {
TrendingUp, TrendingUp,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils"; import { formatCurrency } from "../lib/utils";
@@ -53,7 +53,7 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className="flex items-center space-x-2 hover:opacity-80 transition-opacity" className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
> >
<CreditCard className="h-8 w-8 text-primary" /> <Logo size={32} />
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1> <h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
</Link> </Link>
<button <button

View File

@@ -1,3 +1,6 @@
import { Skeleton } from "./ui/skeleton";
import { Card } from "./ui/card";
interface TransactionSkeletonProps { interface TransactionSkeletonProps {
rows?: number; rows?: number;
view?: "table" | "mobile"; view?: "table" | "mobile";
@@ -11,93 +14,89 @@ export default function TransactionSkeleton({
if (view === "mobile") { if (view === "mobile") {
return ( return (
<div className="bg-white rounded-lg shadow divide-y divide-gray-200"> <Card className="divide-y divide-border">
{skeletonRows.map((_, index) => ( {skeletonRows.map((_, index) => (
<div key={index} className="p-4 animate-pulse"> <div key={index} className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0"> <Skeleton className="h-10 w-10 rounded-full 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"> <div className="flex-1 min-w-0 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <Skeleton className="h-4 w-3/4" />
<div className="space-y-1"> <div className="space-y-1">
<div className="h-3 bg-gray-200 rounded w-1/2"></div> <Skeleton className="h-3 w-1/2" />
<div className="h-3 bg-gray-200 rounded w-2/3"></div> <Skeleton className="h-3 w-2/3" />
<div className="h-3 bg-gray-200 rounded w-1/3"></div> <Skeleton className="h-3 w-1/3" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="text-right ml-3 flex-shrink-0 space-y-2"> <div className="text-right ml-3 flex-shrink-0 space-y-2">
<div className="h-6 bg-gray-200 rounded w-20"></div> <Skeleton className="h-6 w-20" />
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div> <Skeleton className="h-4 w-16 ml-auto" />
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div> <Skeleton className="h-6 w-12 ml-auto" />
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </Card>
); );
} }
return ( return (
<div className="bg-white rounded-lg shadow overflow-hidden"> <Card className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-border">
<thead className="bg-gray-50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-6 py-3 text-left"> <th className="px-6 py-3 text-left">
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div> <Skeleton className="h-4 w-20" />
</th> </th>
<th className="px-6 py-3 text-left"> <th className="px-6 py-3 text-left">
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div> <Skeleton className="h-4 w-16" />
</th> </th>
<th className="px-6 py-3 text-left"> <th className="px-6 py-3 text-left">
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div> <Skeleton className="h-4 w-12" />
</th> </th>
<th className="px-6 py-3 text-left"> <th className="px-6 py-3 text-left">
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div> <Skeleton className="h-4 w-8" />
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-card divide-y divide-border">
{skeletonRows.map((_, index) => ( {skeletonRows.map((_, index) => (
<tr key={index} className="animate-pulse"> <tr key={index}>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0"> <Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="h-4 w-4 bg-gray-300 rounded"></div>
</div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div> <Skeleton className="h-4 w-3/4" />
<div className="space-y-1"> <div className="space-y-1">
<div className="h-3 bg-gray-200 rounded w-1/2"></div> <Skeleton className="h-3 w-1/2" />
<div className="h-3 bg-gray-200 rounded w-2/3"></div> <Skeleton className="h-3 w-2/3" />
</div> </div>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-right"> <div className="text-right">
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div> <Skeleton className="h-6 w-24 ml-auto mb-1" />
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="h-4 bg-gray-200 rounded w-20"></div> <Skeleton className="h-4 w-20" />
<div className="h-3 bg-gray-200 rounded w-16"></div> <Skeleton className="h-3 w-16" />
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="h-6 bg-gray-200 rounded w-12"></div> <Skeleton className="h-6 w-12" />
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </Card>
); );
} }

View File

@@ -31,7 +31,7 @@ import { DataTablePagination } from "./ui/data-table-pagination";
import { Card, CardContent } from "./ui/card"; import { Card, CardContent } 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, Balance } from "../types/api"; import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() { export default function TransactionsTable() {
// Filter state consolidated into a single object // Filter state consolidated into a single object
@@ -47,7 +47,6 @@ export default function TransactionsTable() {
const [showRawModal, setShowRawModal] = useState(false); const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] = const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null); useState<Transaction | null>(null);
const [showRunningBalance, setShowRunningBalance] = useState(true);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -102,11 +101,6 @@ export default function TransactionsTable() {
queryFn: apiClient.getAccounts, queryFn: apiClient.getAccounts,
}); });
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: apiClient.getBalances,
enabled: showRunningBalance,
});
const { const {
data: transactionsResponse, data: transactionsResponse,
@@ -185,53 +179,6 @@ export default function TransactionsTable() {
filterState.minAmount || filterState.minAmount ||
filterState.maxAmount; 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 // Define columns
const columns: ColumnDef<Transaction>[] = [ const columns: ColumnDef<Transaction>[] = [
@@ -308,29 +255,6 @@ export default function TransactionsTable() {
}, },
sortingFn: "basic", 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", accessorKey: "transaction_date",
header: "Date", header: "Date",
@@ -438,7 +362,7 @@ export default function TransactionsTable() {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-full">
{/* New FilterBar */} {/* New FilterBar */}
<FilterBar <FilterBar
filterState={filterState} filterState={filterState}
@@ -446,10 +370,6 @@ export default function TransactionsTable() {
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
accounts={accounts} accounts={accounts}
isSearchLoading={isSearchLoading} isSearchLoading={isSearchLoading}
showRunningBalance={showRunningBalance}
onToggleRunningBalance={() =>
setShowRunningBalance(!showRunningBalance)
}
/> />
{/* Results Summary */} {/* Results Summary */}
@@ -485,10 +405,9 @@ export default function TransactionsTable() {
</Card> </Card>
{/* Responsive Table/Cards */} {/* Responsive Table/Cards */}
<Card className="overflow-hidden"> <Card>
{/* Desktop Table View (hidden on mobile) */} {/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block"> <div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border"> <table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -572,7 +491,6 @@ export default function TransactionsTable() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{/* Mobile Card View (visible only on mobile) */} {/* Mobile Card View (visible only on mobile) */}
<div className="md:hidden"> <div className="md:hidden">
@@ -671,17 +589,6 @@ export default function TransactionsTable() {
transaction.transaction_currency, transaction.transaction_currency,
)} )}
</p> </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 <button
onClick={() => handleViewRaw(transaction)} 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" className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"

View File

@@ -12,6 +12,7 @@ interface StatCardProps {
isPositive: boolean; isPositive: boolean;
}; };
className?: string; className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
} }
export default function StatCard({ export default function StatCard({
@@ -21,23 +22,20 @@ export default function StatCard({
icon: Icon, icon: Icon,
trend, trend,
className, className,
iconColor = "default",
}: StatCardProps) { }: StatCardProps) {
return ( return (
<Card className={cn(className)}> <Card className={cn(className)}>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center"> <div className="flex items-center justify-between">
<div className="flex-shrink-0"> <div>
<Icon className="h-8 w-8 text-primary" /> <p className="text-sm font-medium text-muted-foreground">
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-muted-foreground truncate">
{title} {title}
</dt> </p>
<dd className="flex items-baseline"> <div className="flex items-baseline">
<div className="text-2xl font-semibold text-foreground"> <p className="text-2xl font-bold text-foreground">
{value} {value}
</div> </p>
{trend && ( {trend && (
<div <div
className={cn( className={cn(
@@ -51,13 +49,31 @@ export default function StatCard({
{trend.value}% {trend.value}%
</div> </div>
)} )}
</dd> </div>
{subtitle && ( {subtitle && (
<dd className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{subtitle} {subtitle}
</dd> </p>
)} )}
</dl> </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> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -17,6 +17,7 @@ interface PieDataPoint {
name: string; name: string;
value: number; value: number;
color: string; color: string;
[key: string]: string | number;
} }
interface TooltipProps { interface TooltipProps {

View File

@@ -38,7 +38,8 @@ export function AccountCombobox({
); );
const formatAccountName = (account: Account) => { const formatAccountName = (account: Account) => {
const displayName = account.name || "Unnamed Account"; const displayName =
account.display_name || account.name || "Unnamed Account";
return `${displayName} (${account.institution_id})`; return `${displayName} (${account.institution_id})`;
}; };
@@ -89,7 +90,7 @@ export function AccountCombobox({
{accounts.map((account) => ( {accounts.map((account) => (
<CommandItem <CommandItem
key={account.id} key={account.id}
value={`${account.name} ${account.institution_id}`} value={`${account.display_name || account.name} ${account.institution_id}`}
onSelect={() => { onSelect={() => {
onAccountChange(account.id); onAccountChange(account.id);
setOpen(false); setOpen(false);
@@ -105,7 +106,9 @@ export function AccountCombobox({
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
{account.name || "Unnamed Account"} {account.display_name ||
account.name ||
"Unnamed Account"}
</span> </span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{account.institution_id} {account.institution_id}

View File

@@ -23,8 +23,6 @@ export interface FilterBarProps {
onClearFilters: () => void; onClearFilters: () => void;
accounts?: Account[]; accounts?: Account[];
isSearchLoading?: boolean; isSearchLoading?: boolean;
showRunningBalance: boolean;
onToggleRunningBalance: () => void;
className?: string; className?: string;
} }
@@ -34,8 +32,6 @@ export function FilterBar({
onClearFilters, onClearFilters,
accounts, accounts,
isSearchLoading = false, isSearchLoading = false,
showRunningBalance,
onToggleRunningBalance,
className, className,
}: FilterBarProps) { }: FilterBarProps) {
const hasActiveFilters = const hasActiveFilters =
@@ -59,13 +55,6 @@ export function FilterBar({
<h3 className="text-lg font-semibold text-card-foreground"> <h3 className="text-lg font-semibold text-card-foreground">
Transactions Transactions
</h3> </h3>
<Button
onClick={onToggleRunningBalance}
variant={showRunningBalance ? "default" : "outline"}
size="sm"
>
Balance
</Button>
</div> </div>
{/* Primary Filters Row */} {/* Primary Filters Row */}

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "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",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -0,0 +1,44 @@
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>
);
}

View File

@@ -0,0 +1,26 @@
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 };

View File

@@ -0,0 +1,138 @@
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,
};

View File

@@ -0,0 +1,15 @@
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 };

View File

@@ -0,0 +1,27 @@
"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 };

View File

@@ -10,6 +10,12 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Theme colors for different modes
const THEME_COLORS = {
light: "#0b74de", // Primary brand color
dark: "#0f0f23", // Dark background color that matches typical dark themes
} as const;
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => { const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme; const stored = localStorage.getItem("theme") as Theme;
@@ -40,6 +46,28 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// Add resolved theme class // Add resolved theme class
root.classList.add(resolvedTheme); root.classList.add(resolvedTheme);
// 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) {
themeColorMeta.content = themeColor;
}
// Update Microsoft tile color
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
if (msThemeColorMeta) {
msThemeColorMeta.content = themeColor;
}
// Update Apple status bar style for better iOS integration
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
if (appleStatusBarMeta) {
// Use 'black-translucent' for dark theme, 'default' for light theme
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
}
}; };
updateActualTheme(); updateActualTheme();

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
useEffect(() => {
// Check if SW registration is available
if ("serviceWorker" in navigator) {
// Import the registerSW function
import("virtual:pwa-register").then(({ registerSW }) => {
const updateSWFunction = registerSW({
onNeedRefresh() {
setUpdateAvailable(true);
setUpdateSW(() => updateSWFunction);
},
onOfflineReady() {
console.log("App ready to work offline");
},
});
}).catch(() => {
// PWA not available in development mode or when disabled
console.log("PWA registration not available");
});
}
}, []);
return {
updateAvailable,
updateSW,
};
}

View File

@@ -10,10 +10,10 @@
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%; --primary: 219 91% 46%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 189 94% 43%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%; --muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%; --accent: 210 40% 96.1%;
@@ -37,9 +37,9 @@
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 210 40% 98%; --primary: 219 91% 46%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%; --secondary: 189 94% 43%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%; --muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 215 20.2% 65.1%;

View File

@@ -41,11 +41,10 @@ export const apiClient = {
updateAccount: async ( updateAccount: async (
id: string, id: string,
updates: AccountUpdate, updates: AccountUpdate,
): Promise<{ id: string; name?: string }> => { ): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<ApiResponse<{ id: string; name?: string }>>( const response = await api.put<
`/accounts/${id}`, ApiResponse<{ id: string; display_name?: string }>
updates, >(`/accounts/${id}`, updates);
);
return response.data.data; return response.data.data;
}, },

View File

@@ -2,12 +2,28 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import Header from "../components/Header"; import Header from "../components/Header";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
function RootLayout() { function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const { updateAvailable, updateSW } = usePWA();
const handlePWAInstall = () => {
console.log("PWA installed successfully");
};
const handlePWAUpdate = async () => {
try {
await updateSW();
console.log("PWA updated successfully");
} catch (error) {
console.error("Error updating PWA:", error);
}
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex min-h-screen bg-background">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} /> <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */} {/* Mobile overlay */}
@@ -18,12 +34,19 @@ function RootLayout() {
/> />
)} )}
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1">
<Header setSidebarOpen={setSidebarOpen} /> <Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
</div> </div>
); );
} }

View File

@@ -80,20 +80,21 @@ function AnalyticsDashboard() {
value={stats?.total_transactions || 0} value={stats?.total_transactions || 0}
subtitle={`Last ${stats?.period_days || 0} days`} subtitle={`Last ${stats?.period_days || 0} days`}
icon={Activity} icon={Activity}
iconColor="blue"
/> />
<StatCard <StatCard
title="Total Income" title="Total Income"
value={`${(stats?.total_income || 0).toLocaleString()}`} value={`${(stats?.total_income || 0).toLocaleString()}`}
subtitle="Inflows this period" subtitle="Inflows this period"
icon={TrendingUp} icon={TrendingUp}
className="border-green-200" iconColor="green"
/> />
<StatCard <StatCard
title="Total Expenses" title="Total Expenses"
value={`${(stats?.total_expenses || 0).toLocaleString()}`} value={`${(stats?.total_expenses || 0).toLocaleString()}`}
subtitle="Outflows this period" subtitle="Outflows this period"
icon={TrendingDown} icon={TrendingDown}
className="border-red-200" iconColor="red"
/> />
</div> </div>
@@ -104,23 +105,21 @@ function AnalyticsDashboard() {
value={`${(stats?.net_change || 0).toLocaleString()}`} value={`${(stats?.net_change || 0).toLocaleString()}`}
subtitle="Income minus expenses" subtitle="Income minus expenses"
icon={CreditCard} icon={CreditCard}
className={ iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
(stats?.net_change || 0) >= 0
? "border-green-200"
: "border-red-200"
}
/> />
<StatCard <StatCard
title="Average Transaction" title="Average Transaction"
value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`} value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
subtitle="Per transaction" subtitle="Per transaction"
icon={Activity} icon={Activity}
iconColor="purple"
/> />
<StatCard <StatCard
title="Active Accounts" title="Active Accounts"
value={stats?.accounts_included || 0} value={stats?.accounts_included || 0}
subtitle="With recent activity" subtitle="With recent activity"
icon={Users} icon={Users}
iconColor="orange"
/> />
</div> </div>

View File

@@ -11,6 +11,7 @@ export interface Account {
status: string; status: string;
iban?: string; iban?: string;
name?: string; name?: string;
display_name?: string;
currency?: string; currency?: string;
created: string; created: string;
last_accessed?: string; last_accessed?: string;
@@ -18,7 +19,7 @@ export interface Account {
} }
export interface AccountUpdate { export interface AccountUpdate {
name?: string; display_name?: string;
} }
export interface RawTransactionData { export interface RawTransactionData {

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,10 +1,88 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [TanStackRouterVite(), react()], plugins: [
TanStackRouterVite(),
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
manifest: {
name: "Leggen",
short_name: "Leggen",
description: "Personal finance management application",
theme_color: "#0b74de",
background_color: "#ffffff",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
categories: ["finance", "productivity"],
shortcuts: [
{
name: "Transactions",
short_name: "Transactions",
description: "View and manage transactions",
url: "/transactions",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
},
{
name: "Analytics",
short_name: "Analytics",
description: "View financial analytics",
url: "/analytics",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
}
],
icons: [
{
src: "pwa-64x64.png",
sizes: "64x64",
type: "image/png"
},
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png"
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable"
}
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
networkTimeoutSeconds: 10,
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
},
}),
],
resolve: { resolve: {
alias: { alias: {
"@": "/src", "@": "/src",

View File

@@ -24,6 +24,7 @@ class AccountDetails(BaseModel):
status: str status: str
iban: Optional[str] = None iban: Optional[str] = None
name: Optional[str] = None name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None currency: Optional[str] = None
created: datetime created: datetime
last_accessed: Optional[datetime] = None last_accessed: Optional[datetime] = None
@@ -36,7 +37,7 @@ class AccountDetails(BaseModel):
class AccountUpdate(BaseModel): class AccountUpdate(BaseModel):
"""Account update model""" """Account update model"""
name: Optional[str] = None display_name: Optional[str] = None
class Config: class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None} json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -53,6 +53,7 @@ async def get_all_accounts() -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -112,6 +113,7 @@ async def get_account_details(account_id: str) -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -324,7 +326,7 @@ async def get_account_transactions(
async def update_account_details( async def update_account_details(
account_id: str, update_data: AccountUpdate account_id: str, update_data: AccountUpdate
) -> APIResponse: ) -> APIResponse:
"""Update account details (currently only name)""" """Update account details (currently only display_name)"""
try: try:
# Get current account details # Get current account details
current_account = await database_service.get_account_details_from_db(account_id) current_account = await database_service.get_account_details_from_db(account_id)
@@ -336,16 +338,16 @@ async def update_account_details(
# Prepare updated account data # Prepare updated account data
updated_account_data = current_account.copy() updated_account_data = current_account.copy()
if update_data.name is not None: if update_data.display_name is not None:
updated_account_data["name"] = update_data.name updated_account_data["display_name"] = update_data.display_name
# Persist updated account details # Persist updated account details
await database_service.persist_account_details(updated_account_data) await database_service.persist_account_details(updated_account_data)
return APIResponse( return APIResponse(
success=True, success=True,
data={"id": account_id, "name": update_data.name}, data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} name updated successfully", message=f"Account {account_id} display name updated successfully",
) )
except HTTPException: except HTTPException:

View File

@@ -37,15 +37,15 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook") if discord_config.get("webhook")
else None, else None,
telegram=TelegramConfig( telegram=TelegramConfig(
token="***" if telegram_config.get("api-key") else "", token="***" if telegram_config.get("token") else "",
chat_id=telegram_config.get("chat-id", 0), chat_id=telegram_config.get("chat_id", 0),
enabled=telegram_config.get("enabled", True), enabled=telegram_config.get("enabled", True),
) )
if telegram_config.get("api-key") if telegram_config.get("token")
else None, else None,
filters=NotificationFilters( filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []), case_insensitive=filters_config.get("case_insensitive", []),
case_sensitive=filters_config.get("case-sensitive"), case_sensitive=filters_config.get("case_sensitive"),
), ),
) )
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram: if settings.telegram:
notifications_config["telegram"] = { notifications_config["telegram"] = {
"api-key": settings.telegram.token, "token": settings.telegram.token,
"chat-id": settings.telegram.chat_id, "chat_id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled, "enabled": settings.telegram.enabled,
} }
# Update filters config # Update filters config
filters_config: Dict[str, Any] = {} filters_config: Dict[str, Any] = {}
if settings.filters.case_insensitive: if settings.filters.case_insensitive:
filters_config["case-insensitive"] = settings.filters.case_insensitive filters_config["case_insensitive"] = settings.filters.case_insensitive
if settings.filters.case_sensitive: if settings.filters.case_sensitive:
filters_config["case-sensitive"] = settings.filters.case_sensitive filters_config["case_sensitive"] = settings.filters.case_sensitive
# Save to config # Save to config
if notifications_config: if notifications_config:
@@ -153,12 +153,12 @@ async def get_notification_services() -> APIResponse:
"telegram": { "telegram": {
"name": "Telegram", "name": "Telegram",
"enabled": bool( "enabled": bool(
notifications_config.get("telegram", {}).get("api-key") notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat-id") and notifications_config.get("telegram", {}).get("chat_id")
), ),
"configured": bool( "configured": bool(
notifications_config.get("telegram", {}).get("api-key") notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat-id") and notifications_config.get("telegram", {}).get("chat_id")
), ),
"active": notifications_config.get("telegram", {}).get("enabled", True), "active": notifications_config.get("telegram", {}).get("enabled", True),
}, },

View File

@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
class TelegramNotificationConfig(BaseModel): class TelegramNotificationConfig(BaseModel):
token: str = Field(..., alias="api-key", description="Telegram bot token") token: str = Field(..., description="Telegram bot token")
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID") chat_id: int = Field(..., description="Telegram chat ID")
enabled: bool = Field(default=True, description="Enable Telegram notifications") enabled: bool = Field(default=True, description="Enable Telegram notifications")
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
class FilterConfig(BaseModel): class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field( case_insensitive: Optional[List[str]] = Field(default_factory=list)
default_factory=list, alias="case-insensitive" case_sensitive: Optional[List[str]] = Field(default_factory=list)
)
case_sensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-sensitive"
)
class SyncScheduleConfig(BaseModel): class SyncScheduleConfig(BaseModel):
@@ -60,6 +56,3 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig) scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
class Config:
validate_by_name = True

View File

@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
def send_expire_notification(ctx: click.Context, notification: dict): def send_expire_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["api-key"] token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"] chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage" bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending expiration notification to Telegram") info("Sending expiration notification to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n" message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
@@ -54,8 +54,8 @@ def send_expire_notification(ctx: click.Context, notification: dict):
def send_transaction_message(ctx: click.Context, transactions: list): def send_transaction_message(ctx: click.Context, transactions: list):
token = ctx.obj["notifications"]["telegram"]["api-key"] token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"] chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage" bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info(f"Got {len(transactions)} new transactions, sending message to Telegram") info(f"Got {len(transactions)} new transactions, sending message to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n" message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"

View File

@@ -215,6 +215,7 @@ class DatabaseService:
await self._migrate_balance_timestamps_if_needed() await self._migrate_balance_timestamps_if_needed()
await self._migrate_null_transaction_ids_if_needed() await self._migrate_null_transaction_ids_if_needed()
await self._migrate_to_composite_key_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): async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed""" """Check and migrate balance timestamps if needed"""
@@ -632,6 +633,79 @@ class DatabaseService:
logger.error(f"Composite key migration failed: {e}") logger.error(f"Composite key migration failed: {e}")
raise 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: def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string""" """Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp) dt = datetime.fromtimestamp(unix_timestamp)
@@ -1045,7 +1119,8 @@ class DatabaseService:
currency TEXT, currency TEXT,
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME last_updated DATETIME,
display_name TEXT
)""" )"""
) )
@@ -1060,6 +1135,16 @@ class DatabaseService:
) )
try: 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 # Insert or replace account data
cursor.execute( cursor.execute(
"""INSERT OR REPLACE INTO accounts ( """INSERT OR REPLACE INTO accounts (
@@ -1071,8 +1156,9 @@ class DatabaseService:
currency, currency,
created, created,
last_accessed, last_accessed,
last_updated last_updated,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", display_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
account_data["id"], account_data["id"],
account_data["institution_id"], account_data["institution_id"],
@@ -1083,6 +1169,7 @@ class DatabaseService:
account_data["created"], account_data["created"],
account_data.get("last_accessed"), account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]), account_data.get("last_updated", account_data["created"]),
display_name,
), ),
) )
conn.commit() conn.commit()

View File

@@ -63,8 +63,8 @@ class NotificationService:
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Filter transactions based on notification criteria""" """Filter transactions based on notification criteria"""
matching = [] matching = []
filters_case_insensitive = self.filters_config.get("case-insensitive", []) filters_case_insensitive = self.filters_config.get("case_insensitive", [])
filters_case_sensitive = self.filters_config.get("case-sensitive", []) filters_case_sensitive = self.filters_config.get("case_sensitive", [])
for transaction in transactions: for transaction in transactions:
description = transaction.get("description", "") description = transaction.get("description", "")
@@ -159,8 +159,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }
@@ -219,8 +219,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }
@@ -277,8 +277,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }

View File

@@ -29,7 +29,7 @@ def send_notification(ctx: click.Context, transactions: list):
warning("No filters are enabled, skipping notifications") warning("No filters are enabled, skipping notifications")
return return
filters_case_insensitive = ctx.obj.get("filters", {}).get("case-insensitive", {}) filters_case_insensitive = ctx.obj.get("filters", {}).get("case_insensitive", {})
# Add transaction to the list of transactions to be sent as a notification # Add transaction to the list of transactions to be sent as a notification
notification_transactions = [] notification_transactions = []

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.11" version = "2025.9.14"
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

@@ -106,7 +106,8 @@ class SampleDataGenerator:
currency TEXT, currency TEXT,
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME last_updated DATETIME,
display_name TEXT
) )
""") """)
@@ -373,8 +374,8 @@ class SampleDataGenerator:
cursor.execute( cursor.execute(
""" """
INSERT OR REPLACE INTO accounts INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated) (id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
account["id"], account["id"],
@@ -386,6 +387,7 @@ class SampleDataGenerator:
account["created"], account["created"],
account["last_accessed"], account["last_accessed"],
account["last_updated"], account["last_updated"],
None, # display_name is initially None for sample data
), ),
) )

View File

@@ -24,6 +24,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21", "institution_id": "REVOLUT_REVOLT21",
"status": "READY", "status": "READY",
"iban": "LT313250081177977789", "iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z", "created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z", "last_accessed": "2025-09-01T09:30:00Z",
} }
@@ -80,6 +82,8 @@ class TestAccountsAPI:
"institution_id": "REVOLUT_REVOLT21", "institution_id": "REVOLUT_REVOLT21",
"status": "READY", "status": "READY",
"iban": "LT313250081177977789", "iban": "LT313250081177977789",
"name": "Personal Account",
"display_name": None,
"created": "2024-02-13T23:56:00Z", "created": "2024-02-13T23:56:00Z",
"last_accessed": "2025-09-01T09:30:00Z", "last_accessed": "2025-09-01T09:30:00Z",
} }
@@ -283,3 +287,58 @@ class TestAccountsAPI:
response = api_client.get("/api/v1/accounts/nonexistent") response = api_client.get("/api/v1/accounts/nonexistent")
assert response.status_code == 404 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

View File

@@ -216,8 +216,8 @@ class TestConfig:
"""Test filters configuration access.""" """Test filters configuration access."""
custom_config = { custom_config = {
"filters": { "filters": {
"case-insensitive": ["salary", "utility"], "case_insensitive": ["salary", "utility"],
"case-sensitive": ["SpecificStore"], "case_sensitive": ["SpecificStore"],
} }
} }
@@ -225,6 +225,6 @@ class TestConfig:
config._config = custom_config config._config = custom_config
filters = config.filters_config filters = config.filters_config
assert "salary" in filters["case-insensitive"] assert "salary" in filters["case_insensitive"]
assert "utility" in filters["case-insensitive"] assert "utility" in filters["case_insensitive"]
assert "SpecificStore" in filters["case-sensitive"] assert "SpecificStore" in filters["case_sensitive"]

2
uv.lock generated
View File

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