Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7da446fa5 | ||
|
|
5a626b5394 | ||
|
|
d9a39c30ab | ||
|
|
155a48d7dc | ||
|
|
8ab760815c | ||
|
|
2825dba2e9 | ||
|
|
3049a8cd2f | ||
|
|
86891441d6 | ||
|
|
81d7d16301 | ||
|
|
84e609a774 | ||
|
|
fb310a5953 | ||
|
|
c83386b1d5 |
@@ -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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
.gitignore
vendored
@@ -164,3 +164,4 @@ sql/
|
|||||||
leggen.db
|
leggen.db
|
||||||
*.db
|
*.db
|
||||||
config.toml
|
config.toml
|
||||||
|
.claude/
|
||||||
|
|||||||
17
.mcp.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browsermcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@browsermcp/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
CHANGELOG.md
@@ -1,4 +1,64 @@
|
|||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(find:*)"],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
frontend/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
5473
frontend/package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
9
frontend/public/browserconfig.xml
Normal 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
|
After Width: | Height: | Size: 813 B |
@@ -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 |
BIN
frontend/public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 701 B |
4
frontend/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: /sitemap.xml
|
||||||
4
frontend/pwa-assets.config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"preset": "minimal-2023",
|
||||||
|
"images": ["public/favicon.svg"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,11 +114,7 @@ export default function AccountsOverview() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (accountsLoading) {
|
if (accountsLoading) {
|
||||||
return (
|
return <AccountsSkeleton />;
|
||||||
<Card>
|
|
||||||
<LoadingSpinner message="Loading accounts..." />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountsError) {
|
if (accountsError) {
|
||||||
@@ -201,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>
|
||||||
@@ -304,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.display_name || account.name || "Unnamed Account"}
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
|
|||||||
61
frontend/src/components/AccountsSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<div className="flex items-center justify-center text-center">
|
<CardContent className="p-6">
|
||||||
<div>
|
<div className="flex items-center justify-center text-center">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
Something went wrong
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
</h3>
|
Something went wrong
|
||||||
<p className="text-gray-600 mb-4">
|
</h3>
|
||||||
An error occurred while rendering this component. Please try
|
<p className="text-muted-foreground mb-4">
|
||||||
refreshing or check the console for more details.
|
An error occurred while rendering this component. Please try
|
||||||
</p>
|
refreshing or check the console for more details.
|
||||||
|
</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" />
|
||||||
<strong>Error:</strong> {this.state.error.message}
|
<AlertTitle>Error Details</AlertTitle>
|
||||||
</p>
|
<AlertDescription className="space-y-2">
|
||||||
{this.state.error.stack && (
|
<p className="text-sm font-mono">
|
||||||
<details className="mt-2">
|
<strong>Error:</strong> {this.state.error.message}
|
||||||
<summary className="text-sm text-red-600 cursor-pointer">
|
</p>
|
||||||
Stack trace
|
{this.state.error.stack && (
|
||||||
</summary>
|
<details className="mt-2">
|
||||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
<summary className="text-sm cursor-pointer">
|
||||||
{this.state.error.stack}
|
Stack trace
|
||||||
</pre>
|
</summary>
|
||||||
</details>
|
<pre className="text-xs mt-1 whitespace-pre-wrap">
|
||||||
)}
|
{this.state.error.stack}
|
||||||
</div>
|
</pre>
|
||||||
)}
|
</details>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<Button onClick={this.handleReset}>
|
||||||
onClick={this.handleReset}
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
Try Again
|
||||||
>
|
</Button>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
</div>
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
95
frontend/src/components/NotificationsSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/components/PWAPrompts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,93 +405,91 @@ 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) => (
|
<tr key={headerGroup.id}>
|
||||||
<tr key={headerGroup.id}>
|
{headerGroup.headers.map((header) => (
|
||||||
{headerGroup.headers.map((header) => (
|
<th
|
||||||
<th
|
key={header.id}
|
||||||
key={header.id}
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ChevronUp
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
header.column.getIsSorted() === "asc"
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 -mt-1 ${
|
||||||
|
header.column.getIsSorted() === "desc"
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-card divide-y divide-border">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id} className="hover:bg-muted/50">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className="px-6 py-4 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-1">
|
{flexRender(
|
||||||
<span>
|
cell.column.columnDef.cell,
|
||||||
{header.isPlaceholder
|
cell.getContext(),
|
||||||
? null
|
)}
|
||||||
: flexRender(
|
</td>
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{header.column.getCanSort() && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<ChevronUp
|
|
||||||
className={`h-3 w-3 ${
|
|
||||||
header.column.getIsSorted() === "asc"
|
|
||||||
? "text-primary"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 -mt-1 ${
|
|
||||||
header.column.getIsSorted() === "desc"
|
|
||||||
? "text-primary"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
</thead>
|
)}
|
||||||
<tbody className="bg-card divide-y divide-border">
|
</tbody>
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
</table>
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="px-6 py-12 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-muted-foreground mb-4">
|
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
No transactions found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{hasActiveFilters
|
|
||||||
? "Try adjusting your filters to see more results."
|
|
||||||
: "No transactions are available for the selected criteria."}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-muted/50">
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className="px-6 py-4 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Card View (visible only on mobile) */}
|
{/* Mobile Card View (visible only on mobile) */}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,43 +22,58 @@ 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>
|
{title}
|
||||||
<div className="ml-5 w-0 flex-1">
|
</p>
|
||||||
<dl>
|
<div className="flex items-baseline">
|
||||||
<dt className="text-sm font-medium text-muted-foreground truncate">
|
<p className="text-2xl font-bold text-foreground">
|
||||||
{title}
|
{value}
|
||||||
</dt>
|
</p>
|
||||||
<dd className="flex items-baseline">
|
{trend && (
|
||||||
<div className="text-2xl font-semibold text-foreground">
|
<div
|
||||||
{value}
|
className={cn(
|
||||||
|
"ml-2 flex items-baseline text-sm font-semibold",
|
||||||
|
trend.isPositive
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? "+" : ""}
|
||||||
|
{trend.value}%
|
||||||
</div>
|
</div>
|
||||||
{trend && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex items-baseline text-sm font-semibold",
|
|
||||||
trend.isPositive
|
|
||||||
? "text-green-600 dark:text-green-400"
|
|
||||||
: "text-red-600 dark:text-red-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{trend.isPositive ? "+" : ""}
|
|
||||||
{trend.value}%
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
{subtitle && (
|
|
||||||
<dd className="text-sm text-muted-foreground mt-1">
|
|
||||||
{subtitle}
|
|
||||||
</dd>
|
|
||||||
)}
|
)}
|
||||||
</dl>
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export function AccountCombobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formatAccountName = (account: Account) => {
|
const formatAccountName = (account: Account) => {
|
||||||
const displayName = account.display_name || account.name || "Unnamed Account";
|
const displayName =
|
||||||
|
account.display_name || account.name || "Unnamed Account";
|
||||||
return `${displayName} (${account.institution_id})`;
|
return `${displayName} (${account.institution_id})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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.display_name || 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}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
44
frontend/src/components/ui/logo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/progress.tsx
Normal 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 };
|
||||||
138
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
};
|
||||||
15
frontend/src/components/ui/skeleton.tsx
Normal 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 };
|
||||||
27
frontend/src/components/ui/sonner.tsx
Normal 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 };
|
||||||
@@ -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();
|
||||||
|
|||||||
37
frontend/src/hooks/usePWA.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ export const apiClient = {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: AccountUpdate,
|
updates: AccountUpdate,
|
||||||
): Promise<{ id: string; display_name?: string }> => {
|
): Promise<{ id: string; display_name?: string }> => {
|
||||||
const response = await api.put<ApiResponse<{ id: string; display_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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.12"
|
version = "2025.9.13"
|
||||||
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"
|
||||||
|
|||||||
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.12"
|
version = "2025.9.13"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||