Compare commits
8 Commits
f7d828f669
...
2025.9.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7da446fa5 | ||
|
|
5a626b5394 | ||
|
|
d9a39c30ab | ||
|
|
155a48d7dc | ||
|
|
8ab760815c | ||
|
|
2825dba2e9 | ||
|
|
3049a8cd2f | ||
|
|
86891441d6 |
@@ -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>
|
||||||
|
|||||||
5005
frontend/package-lock.json
generated
@@ -42,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"]
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||