1154 lines
34 KiB
Markdown
1154 lines
34 KiB
Markdown
# COMPLETE SOURCE CODE FOR FIREFLY III ANALYTICS
|
|
|
|
This file contains all source code for the application. Each section is clearly labeled with the file path.
|
|
|
|
## BACKEND FILES
|
|
|
|
### backend/pyproject.toml
|
|
```toml
|
|
[project]
|
|
name = "firefly-analytics-backend"
|
|
version = "0.1.0"
|
|
description = "Firefly III Analytics & Reporting Backend"
|
|
requires-python = ">=3.11"
|
|
|
|
dependencies = [
|
|
"fastapi==0.104.1",
|
|
"uvicorn[standard]==0.24.0",
|
|
"httpx==0.25.2",
|
|
"python-dotenv==1.0.0",
|
|
"pydantic==2.5.0",
|
|
"pydantic-settings==2.1.0",
|
|
"sqlalchemy==2.0.23",
|
|
"python-dateutil==2.8.2",
|
|
"apscheduler==3.10.4",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest==7.4.3",
|
|
"pytest-asyncio==0.21.1",
|
|
"black==23.12.0",
|
|
"ruff==0.1.8",
|
|
]
|
|
```
|
|
|
|
### backend/Dockerfile
|
|
```dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
WORKDIR /app
|
|
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
gcc \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY pyproject.toml pyproject.toml
|
|
COPY app/ app/
|
|
|
|
RUN pip install --no-cache-dir -e .
|
|
RUN mkdir -p /app/data
|
|
|
|
EXPOSE 8000
|
|
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
```
|
|
|
|
### backend/app/__init__.py
|
|
```python
|
|
# Firefly Analytics App
|
|
```
|
|
|
|
### backend/app/config.py
|
|
```python
|
|
import os
|
|
from pydantic_settings import BaseSettings
|
|
|
|
class Settings(BaseSettings):
|
|
FIREFLY_URL: str = os.getenv("FIREFLY_URL", "https://firefly.scsimedia.duckdns.org")
|
|
FIREFLY_API_TOKEN: str = os.getenv("FIREFLY_API_TOKEN", "")
|
|
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
|
SYNC_INTERVAL_MINUTES: int = int(os.getenv("SYNC_INTERVAL_MINUTES", "30"))
|
|
API_TIMEOUT: int = 30
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
|
|
settings = Settings()
|
|
```
|
|
|
|
### backend/app/models.py
|
|
```python
|
|
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import relationship
|
|
from datetime import datetime
|
|
|
|
Base = declarative_base()
|
|
|
|
class Transaction(Base):
|
|
__tablename__ = "transactions"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
firefly_id = Column(Integer, unique=True, index=True)
|
|
date = Column(DateTime, index=True)
|
|
amount = Column(Float)
|
|
description = Column(String)
|
|
type = Column(String)
|
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
|
from_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
|
|
to_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
|
|
notes = Column(Text, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
category = relationship("Category", foreign_keys=[category_id], back_populates="transactions")
|
|
from_account = relationship("Account", foreign_keys=[from_account_id])
|
|
to_account = relationship("Account", foreign_keys=[to_account_id])
|
|
|
|
class Category(Base):
|
|
__tablename__ = "categories"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
firefly_id = Column(Integer, unique=True, index=True)
|
|
name = Column(String, unique=True, index=True)
|
|
type = Column(String)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
transactions = relationship("Transaction", foreign_keys=[Transaction.category_id], back_populates="category")
|
|
|
|
class Account(Base):
|
|
__tablename__ = "accounts"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
firefly_id = Column(Integer, unique=True, index=True)
|
|
name = Column(String, unique=True, index=True)
|
|
type = Column(String)
|
|
balance = Column(Float)
|
|
currency_code = Column(String)
|
|
active = Column(Boolean, default=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
class SyncLog(Base):
|
|
__tablename__ = "sync_logs"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
entity_type = Column(String)
|
|
last_sync = Column(DateTime)
|
|
status = Column(String)
|
|
message = Column(String, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
```
|
|
|
|
### backend/app/database.py
|
|
```python
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker, Session
|
|
from app.config import settings
|
|
from app.models import Base
|
|
|
|
engine = create_engine(
|
|
settings.DATABASE_URL,
|
|
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
|
)
|
|
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
def init_db():
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
def get_db() -> Session:
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
```
|
|
|
|
### backend/app/main.py
|
|
```python
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from dotenv import load_dotenv
|
|
|
|
from app.database import init_db
|
|
from app.routers import transactions, categories, accounts, reports, summary
|
|
from app.services.sync_service import start_sync_scheduler, stop_sync_scheduler
|
|
|
|
load_dotenv()
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
start_sync_scheduler()
|
|
yield
|
|
stop_sync_scheduler()
|
|
|
|
app = FastAPI(
|
|
title="Firefly III Analytics API",
|
|
description="Analytics and reporting for Firefly III",
|
|
version="0.1.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:3000", "http://localhost"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(transactions.router, prefix="/api/transactions", tags=["transactions"])
|
|
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
|
|
app.include_router(accounts.router, prefix="/api/accounts", tags=["accounts"])
|
|
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
|
|
app.include_router(summary.router, prefix="/api/summary", tags=["summary"])
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {"status": "ok", "message": "Firefly III Analytics API"}
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy"}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
```
|
|
|
|
### backend/app/routers/__init__.py
|
|
```python
|
|
# API routers
|
|
```
|
|
|
|
### backend/app/routers/transactions.py
|
|
```python
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.orm import Session
|
|
from app.database import get_db
|
|
from datetime import datetime
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/")
|
|
async def get_transactions(
|
|
start_date: str = Query(None),
|
|
end_date: str = Query(None),
|
|
category_id: int = Query(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
from app.models import Transaction
|
|
query = db.query(Transaction)
|
|
|
|
if start_date:
|
|
query = query.filter(Transaction.date >= datetime.fromisoformat(start_date))
|
|
if end_date:
|
|
query = query.filter(Transaction.date <= datetime.fromisoformat(end_date))
|
|
if category_id:
|
|
query = query.filter(Transaction.category_id == category_id)
|
|
|
|
transactions = query.order_by(Transaction.date.desc()).all()
|
|
return {"data": transactions, "count": len(transactions)}
|
|
```
|
|
|
|
### backend/app/routers/categories.py
|
|
```python
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
from app.database import get_db
|
|
from app.models import Category
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/")
|
|
async def get_categories(db: Session = Depends(get_db)):
|
|
categories = db.query(Category).all()
|
|
return {"data": categories, "count": len(categories)}
|
|
```
|
|
|
|
### backend/app/routers/accounts.py
|
|
```python
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
from app.database import get_db
|
|
from app.models import Account
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/")
|
|
async def get_accounts(db: Session = Depends(get_db)):
|
|
accounts = db.query(Account).all()
|
|
return {"data": accounts, "count": len(accounts)}
|
|
```
|
|
|
|
### backend/app/routers/reports.py
|
|
```python
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
from app.database import get_db
|
|
from app.models import Transaction, Category
|
|
from datetime import datetime
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/spending-by-category")
|
|
async def spending_by_category(
|
|
start_date: str = Query(...),
|
|
end_date: str = Query(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
start = datetime.fromisoformat(start_date)
|
|
end = datetime.fromisoformat(end_date)
|
|
|
|
results = db.query(
|
|
Category.name,
|
|
func.sum(Transaction.amount).label("total")
|
|
).join(
|
|
Transaction
|
|
).filter(
|
|
Transaction.date >= start,
|
|
Transaction.date <= end,
|
|
Transaction.type == "withdrawal"
|
|
).group_by(Category.name).all()
|
|
|
|
return {
|
|
"period": {"start": start.isoformat(), "end": end.isoformat()},
|
|
"data": [{"category": r[0], "amount": float(r[1]) if r[1] else 0} for r in results]
|
|
}
|
|
|
|
@router.get("/income-vs-expenses")
|
|
async def income_vs_expenses(
|
|
start_date: str = Query(...),
|
|
end_date: str = Query(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
start = datetime.fromisoformat(start_date)
|
|
end = datetime.fromisoformat(end_date)
|
|
|
|
income = db.query(func.sum(Transaction.amount)).filter(
|
|
Transaction.date >= start,
|
|
Transaction.date <= end,
|
|
Transaction.type == "deposit"
|
|
).scalar() or 0
|
|
|
|
expenses = db.query(func.sum(Transaction.amount)).filter(
|
|
Transaction.date >= start,
|
|
Transaction.date <= end,
|
|
Transaction.type == "withdrawal"
|
|
).scalar() or 0
|
|
|
|
return {
|
|
"period": {"start": start.isoformat(), "end": end.isoformat()},
|
|
"income": float(income),
|
|
"expenses": float(expenses),
|
|
"net": float(income - expenses)
|
|
}
|
|
|
|
@router.get("/trends")
|
|
async def trends(
|
|
start_date: str = Query(...),
|
|
end_date: str = Query(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
start = datetime.fromisoformat(start_date)
|
|
end = datetime.fromisoformat(end_date)
|
|
|
|
results = db.query(
|
|
func.date(Transaction.date).label("date"),
|
|
func.sum(Transaction.amount).label("amount")
|
|
).filter(
|
|
Transaction.date >= start,
|
|
Transaction.date <= end
|
|
).group_by(func.date(Transaction.date)).order_by("date").all()
|
|
|
|
return {
|
|
"period": {"start": start.isoformat(), "end": end.isoformat()},
|
|
"data": [{"date": str(r[0]), "amount": float(r[1]) if r[1] else 0} for r in results]
|
|
}
|
|
```
|
|
|
|
### backend/app/routers/summary.py
|
|
```python
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
from app.database import get_db
|
|
from app.models import Transaction, Account
|
|
from datetime import datetime, timedelta
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/")
|
|
async def get_summary(db: Session = Depends(get_db)):
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
|
|
total_assets = db.query(func.sum(Account.balance)).scalar() or 0
|
|
recent_transactions = db.query(func.count(Transaction.id)).filter(
|
|
Transaction.date >= thirty_days_ago
|
|
).scalar() or 0
|
|
|
|
income = db.query(func.sum(Transaction.amount)).filter(
|
|
Transaction.date >= thirty_days_ago,
|
|
Transaction.type == "deposit"
|
|
).scalar() or 0
|
|
|
|
expenses = db.query(func.sum(Transaction.amount)).filter(
|
|
Transaction.date >= thirty_days_ago,
|
|
Transaction.type == "withdrawal"
|
|
).scalar() or 0
|
|
|
|
return {
|
|
"total_assets": float(total_assets),
|
|
"recent_transactions": recent_transactions,
|
|
"last_30_days": {
|
|
"income": float(income),
|
|
"expenses": float(expenses),
|
|
"net": float(income - expenses)
|
|
}
|
|
}
|
|
```
|
|
|
|
### backend/app/clients/__init__.py
|
|
```python
|
|
# API clients
|
|
```
|
|
|
|
### backend/app/clients/firefly_client.py
|
|
```python
|
|
import httpx
|
|
from typing import Optional, List, Dict, Any
|
|
from app.config import settings
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class FireflyClient:
|
|
def __init__(self):
|
|
self.base_url = settings.FIREFLY_URL.rstrip('/')
|
|
self.headers = {
|
|
"Authorization": f"Bearer {settings.FIREFLY_API_TOKEN}",
|
|
"Accept": "application/vnd.api+json",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
async def get_transactions(
|
|
self,
|
|
start_date: Optional[str] = None,
|
|
end_date: Optional[str] = None,
|
|
limit: int = 50
|
|
) -> List[Dict[str, Any]]:
|
|
async with httpx.AsyncClient() as client:
|
|
params = {"limit": limit}
|
|
if start_date:
|
|
params["start"] = start_date
|
|
if end_date:
|
|
params["end"] = end_date
|
|
|
|
try:
|
|
response = await client.get(
|
|
f"{self.base_url}/api/v1/transactions",
|
|
headers=self.headers,
|
|
params=params,
|
|
timeout=settings.API_TIMEOUT
|
|
)
|
|
response.raise_for_status()
|
|
return response.json().get("data", [])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching transactions: {e}")
|
|
return []
|
|
|
|
async def get_categories(self) -> List[Dict[str, Any]]:
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.base_url}/api/v1/categories",
|
|
headers=self.headers,
|
|
timeout=settings.API_TIMEOUT
|
|
)
|
|
response.raise_for_status()
|
|
return response.json().get("data", [])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching categories: {e}")
|
|
return []
|
|
|
|
async def get_accounts(self) -> List[Dict[str, Any]]:
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.base_url}/api/v1/accounts",
|
|
headers=self.headers,
|
|
timeout=settings.API_TIMEOUT
|
|
)
|
|
response.raise_for_status()
|
|
return response.json().get("data", [])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching accounts: {e}")
|
|
return []
|
|
|
|
firefly_client = FireflyClient()
|
|
```
|
|
|
|
### backend/app/services/__init__.py
|
|
```python
|
|
# Services
|
|
```
|
|
|
|
### backend/app/services/sync_service.py
|
|
```python
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
from app.config import settings
|
|
from app.clients.firefly_client import firefly_client
|
|
from app.database import SessionLocal
|
|
from app.models import Transaction, Category, Account, SyncLog
|
|
from datetime import datetime
|
|
import logging
|
|
import asyncio
|
|
|
|
logger = logging.getLogger(__name__)
|
|
scheduler = BackgroundScheduler()
|
|
|
|
async def sync_data():
|
|
db = SessionLocal()
|
|
try:
|
|
# Sync categories
|
|
categories = await firefly_client.get_categories()
|
|
for cat in categories:
|
|
attrs = cat.get("attributes", {})
|
|
existing = db.query(Category).filter_by(firefly_id=cat["id"]).first()
|
|
if existing:
|
|
existing.name = attrs.get("name")
|
|
existing.type = attrs.get("type")
|
|
existing.updated_at = datetime.utcnow()
|
|
else:
|
|
db.add(Category(
|
|
firefly_id=cat["id"],
|
|
name=attrs.get("name"),
|
|
type=attrs.get("type")
|
|
))
|
|
|
|
# Sync accounts
|
|
accounts = await firefly_client.get_accounts()
|
|
for acc in accounts:
|
|
attrs = acc.get("attributes", {})
|
|
existing = db.query(Account).filter_by(firefly_id=acc["id"]).first()
|
|
if existing:
|
|
existing.name = attrs.get("name")
|
|
existing.balance = float(attrs.get("current_balance", 0))
|
|
existing.updated_at = datetime.utcnow()
|
|
else:
|
|
db.add(Account(
|
|
firefly_id=acc["id"],
|
|
name=attrs.get("name"),
|
|
type=attrs.get("type"),
|
|
balance=float(attrs.get("current_balance", 0)),
|
|
currency_code=attrs.get("currency_code")
|
|
))
|
|
|
|
# Sync transactions
|
|
transactions = await firefly_client.get_transactions()
|
|
for txn in transactions:
|
|
attrs = txn.get("attributes", {})
|
|
existing = db.query(Transaction).filter_by(firefly_id=txn["id"]).first()
|
|
if not existing:
|
|
db.add(Transaction(
|
|
firefly_id=txn["id"],
|
|
date=datetime.fromisoformat(attrs.get("date")),
|
|
amount=float(attrs.get("amount", 0)),
|
|
description=attrs.get("description"),
|
|
type=attrs.get("type"),
|
|
notes=attrs.get("notes")
|
|
))
|
|
|
|
db.commit()
|
|
logger.info("Data sync completed successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error syncing data: {e}")
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|
|
|
|
def sync_job():
|
|
asyncio.run(sync_data())
|
|
|
|
def start_sync_scheduler():
|
|
if not scheduler.running:
|
|
scheduler.add_job(sync_job, 'interval', minutes=settings.SYNC_INTERVAL_MINUTES)
|
|
scheduler.start()
|
|
logger.info(f"Sync scheduler started (interval: {settings.SYNC_INTERVAL_MINUTES} minutes)")
|
|
|
|
def stop_sync_scheduler():
|
|
if scheduler.running:
|
|
scheduler.shutdown()
|
|
logger.info("Sync scheduler stopped")
|
|
```
|
|
|
|
## FRONTEND FILES
|
|
|
|
### frontend/package.json
|
|
```json
|
|
{
|
|
"name": "firefly-analytics-frontend",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"dependencies": {
|
|
"@testing-library/jest-dom": "^5.17.0",
|
|
"@testing-library/react": "^13.4.0",
|
|
"@testing-library/user-event": "^13.5.0",
|
|
"@types/node": "^20.10.6",
|
|
"@types/react": "^18.2.45",
|
|
"@types/react-dom": "^18.2.18",
|
|
"axios": "^1.6.2",
|
|
"react": "^18.2.0",
|
|
"react-dom": "^18.2.0",
|
|
"react-scripts": "5.0.1",
|
|
"recharts": "^2.10.3",
|
|
"typescript": "^4.9.5",
|
|
"web-vitals": "^2.1.4"
|
|
},
|
|
"scripts": {
|
|
"start": "react-scripts start",
|
|
"build": "react-scripts build",
|
|
"test": "react-scripts test",
|
|
"eject": "react-scripts eject"
|
|
},
|
|
"eslintConfig": {
|
|
"extends": [
|
|
"react-app"
|
|
]
|
|
},
|
|
"browserslist": {
|
|
"production": [
|
|
">0.2%",
|
|
"not dead",
|
|
"not op_mini all"
|
|
],
|
|
"development": [
|
|
"last 1 chrome version",
|
|
"last 1 firefox version",
|
|
"last 1 safari version"
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### frontend/Dockerfile
|
|
```dockerfile
|
|
FROM node:18-alpine AS build
|
|
|
|
WORKDIR /app
|
|
|
|
COPY package*.json ./
|
|
RUN npm install
|
|
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM nginx:alpine
|
|
|
|
COPY --from=build /app/build /usr/share/nginx/html
|
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
|
|
EXPOSE 3000
|
|
|
|
CMD ["nginx", "-g", "daemon off;"]
|
|
```
|
|
|
|
### frontend/nginx.conf
|
|
```nginx
|
|
server {
|
|
listen 3000;
|
|
location / {
|
|
root /usr/share/nginx/html;
|
|
index index.html index.htm;
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
location /api {
|
|
proxy_pass http://backend:8000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
}
|
|
```
|
|
|
|
### frontend/src/types.ts
|
|
```typescript
|
|
export interface Transaction {
|
|
id: number;
|
|
firefly_id: number;
|
|
date: string;
|
|
amount: number;
|
|
description: string;
|
|
type: 'withdrawal' | 'deposit' | 'transfer';
|
|
category_id?: number;
|
|
}
|
|
|
|
export interface Category {
|
|
id: number;
|
|
firefly_id: number;
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
export interface Account {
|
|
id: number;
|
|
firefly_id: number;
|
|
name: string;
|
|
type: string;
|
|
balance: number;
|
|
currency_code: string;
|
|
}
|
|
|
|
export interface ReportData {
|
|
period: {
|
|
start: string;
|
|
end: string;
|
|
};
|
|
data: any[];
|
|
}
|
|
|
|
export interface SummaryData {
|
|
total_assets: number;
|
|
recent_transactions: number;
|
|
last_30_days: {
|
|
income: number;
|
|
expenses: number;
|
|
net: number;
|
|
};
|
|
}
|
|
```
|
|
|
|
### frontend/src/services/api.ts
|
|
```typescript
|
|
import axios from 'axios';
|
|
import { Transaction, Category, Account, ReportData, SummaryData } from '../types';
|
|
|
|
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
|
|
|
const api = axios.create({
|
|
baseURL: API_URL,
|
|
});
|
|
|
|
export const transactionService = {
|
|
getTransactions: (startDate?: string, endDate?: string) =>
|
|
api.get<{ data: Transaction[] }>('/api/transactions', {
|
|
params: { start_date: startDate, end_date: endDate },
|
|
}),
|
|
};
|
|
|
|
export const categoryService = {
|
|
getCategories: () =>
|
|
api.get<{ data: Category[] }>('/api/categories'),
|
|
};
|
|
|
|
export const accountService = {
|
|
getAccounts: () =>
|
|
api.get<{ data: Account[] }>('/api/accounts'),
|
|
};
|
|
|
|
export const reportService = {
|
|
getSpendingByCategory: (startDate: string, endDate: string) =>
|
|
api.get<ReportData>('/api/reports/spending-by-category', {
|
|
params: { start_date: startDate, end_date: endDate },
|
|
}),
|
|
getIncomeVsExpenses: (startDate: string, endDate: string) =>
|
|
api.get<any>('/api/reports/income-vs-expenses', {
|
|
params: { start_date: startDate, end_date: endDate },
|
|
}),
|
|
getTrends: (startDate: string, endDate: string) =>
|
|
api.get<ReportData>('/api/reports/trends', {
|
|
params: { start_date: startDate, end_date: endDate },
|
|
}),
|
|
};
|
|
|
|
export const summaryService = {
|
|
getSummary: () =>
|
|
api.get<SummaryData>('/api/summary'),
|
|
};
|
|
```
|
|
|
|
### frontend/src/components/DateRangePicker.tsx
|
|
```typescript
|
|
import React from 'react';
|
|
|
|
interface DateRangePickerProps {
|
|
startDate: string;
|
|
endDate: string;
|
|
onStartDateChange: (date: string) => void;
|
|
onEndDateChange: (date: string) => void;
|
|
onPreset: (days: number) => void;
|
|
}
|
|
|
|
const DateRangePicker: React.FC<DateRangePickerProps> = ({
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange,
|
|
onEndDateChange,
|
|
onPreset,
|
|
}) => {
|
|
return (
|
|
<div style={{ marginBottom: '20px', padding: '15px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
|
<div style={{ display: 'flex', gap: '10px', marginBottom: '10px' }}>
|
|
<button onClick={() => onPreset(30)}>Last 30 Days</button>
|
|
<button onClick={() => onPreset(90)}>Last 90 Days</button>
|
|
<button onClick={() => onPreset(365)}>Last Year</button>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => onStartDateChange(e.target.value)}
|
|
placeholder="Start Date"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => onEndDateChange(e.target.value)}
|
|
placeholder="End Date"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DateRangePicker;
|
|
```
|
|
|
|
### frontend/src/components/SpendingChart.tsx
|
|
```typescript
|
|
import React from 'react';
|
|
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
|
|
|
|
interface SpendingChartProps {
|
|
data: Array<{ category: string; amount: number }>;
|
|
}
|
|
|
|
const COLORS = [
|
|
'#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8',
|
|
'#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1', '#d084d0',
|
|
];
|
|
|
|
const SpendingChart: React.FC<SpendingChartProps> = ({ data }) => {
|
|
if (!data || data.length === 0) {
|
|
return <p>No data available</p>;
|
|
}
|
|
|
|
return (
|
|
<ResponsiveContainer width="100%" height={400}>
|
|
<PieChart>
|
|
<Pie
|
|
data={data}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ category, amount }) => `${category}: $${amount.toFixed(2)}`}
|
|
outerRadius={120}
|
|
fill="#8884d8"
|
|
dataKey="amount"
|
|
>
|
|
{data.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
};
|
|
|
|
export default SpendingChart;
|
|
```
|
|
|
|
### frontend/src/components/TrendChart.tsx
|
|
```typescript
|
|
import React from 'react';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
|
|
interface TrendChartProps {
|
|
data: Array<{ date: string; amount: number }>;
|
|
}
|
|
|
|
const TrendChart: React.FC<TrendChartProps> = ({ data }) => {
|
|
if (!data || data.length === 0) {
|
|
return <p>No data available</p>;
|
|
}
|
|
|
|
return (
|
|
<ResponsiveContainer width="100%" height={400}>
|
|
<LineChart data={data}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="date" />
|
|
<YAxis />
|
|
<Tooltip formatter={(value: number) => `$${value.toFixed(2)}`} />
|
|
<Legend />
|
|
<Line type="monotone" dataKey="amount" stroke="#8884d8" />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
};
|
|
|
|
export default TrendChart;
|
|
```
|
|
|
|
### frontend/src/pages/Dashboard.tsx
|
|
```typescript
|
|
import React, { useState, useEffect } from 'react';
|
|
import { summaryService } from '../services/api';
|
|
import { SummaryData } from '../types';
|
|
|
|
const Dashboard: React.FC = () => {
|
|
const [summary, setSummary] = useState<SummaryData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchSummary = async () => {
|
|
try {
|
|
const response = await summaryService.getSummary();
|
|
setSummary(response.data);
|
|
} catch (error) {
|
|
console.error('Error fetching summary:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchSummary();
|
|
}, []);
|
|
|
|
if (loading) return <p>Loading...</p>;
|
|
if (!summary) return <p>Error loading data</p>;
|
|
|
|
return (
|
|
<div style={{ padding: '20px' }}>
|
|
<h1>Financial Dashboard</h1>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px', marginTop: '20px' }}>
|
|
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
|
|
<h3>Total Assets</h3>
|
|
<p style={{ fontSize: '24px', fontWeight: 'bold' }}>${summary.total_assets.toFixed(2)}</p>
|
|
</div>
|
|
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
|
|
<h3>Last 30 Days Income</h3>
|
|
<p style={{ fontSize: '24px', fontWeight: 'bold', color: '#00C49F' }}>
|
|
${summary.last_30_days.income.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div style={{ backgroundColor: '#f5f5f5', padding: '20px', borderRadius: '8px' }}>
|
|
<h3>Last 30 Days Expenses</h3>
|
|
<p style={{ fontSize: '24px', fontWeight: 'bold', color: '#FF8042' }}>
|
|
${summary.last_30_days.expenses.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|
|
```
|
|
|
|
### frontend/src/pages/ReportsPage.tsx
|
|
```typescript
|
|
import React, { useState, useEffect } from 'react';
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
import SpendingChart from '../components/SpendingChart';
|
|
import TrendChart from '../components/TrendChart';
|
|
import { reportService } from '../services/api';
|
|
import { ReportData } from '../types';
|
|
|
|
const ReportsPage: React.FC = () => {
|
|
const [reportType, setReportType] = useState('spending-by-category');
|
|
const [startDate, setStartDate] = useState(
|
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
);
|
|
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
|
|
const [reportData, setReportData] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handlePreset = (days: number) => {
|
|
const end = new Date();
|
|
const start = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
setStartDate(start.toISOString().split('T')[0]);
|
|
setEndDate(end.toISOString().split('T')[0]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchReport = async () => {
|
|
setLoading(true);
|
|
try {
|
|
let response;
|
|
if (reportType === 'spending-by-category') {
|
|
response = await reportService.getSpendingByCategory(startDate, endDate);
|
|
} else if (reportType === 'income-vs-expenses') {
|
|
response = await reportService.getIncomeVsExpenses(startDate, endDate);
|
|
} else if (reportType === 'trends') {
|
|
response = await reportService.getTrends(startDate, endDate);
|
|
}
|
|
setReportData(response.data);
|
|
} catch (error) {
|
|
console.error('Error fetching report:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchReport();
|
|
}, [reportType, startDate, endDate]);
|
|
|
|
return (
|
|
<div style={{ padding: '20px' }}>
|
|
<h1>Reports</h1>
|
|
|
|
<DateRangePicker
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartDateChange={setStartDate}
|
|
onEndDateChange={setEndDate}
|
|
onPreset={handlePreset}
|
|
/>
|
|
|
|
<select value={reportType} onChange={(e) => setReportType(e.target.value)} style={{ marginBottom: '20px', padding: '10px' }}>
|
|
<option value="spending-by-category">Spending by Category</option>
|
|
<option value="income-vs-expenses">Income vs Expenses</option>
|
|
<option value="trends">Trends</option>
|
|
</select>
|
|
|
|
{loading ? (
|
|
<p>Loading report...</p>
|
|
) : reportData ? (
|
|
<>
|
|
{reportType === 'spending-by-category' && <SpendingChart data={reportData.data} />}
|
|
{reportType === 'income-vs-expenses' && (
|
|
<div>
|
|
<p>Income: ${reportData.income.toFixed(2)}</p>
|
|
<p>Expenses: ${reportData.expenses.toFixed(2)}</p>
|
|
<p>Net: ${reportData.net.toFixed(2)}</p>
|
|
</div>
|
|
)}
|
|
{reportType === 'trends' && <TrendChart data={reportData.data} />}
|
|
</>
|
|
) : (
|
|
<p>No data available</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ReportsPage;
|
|
```
|
|
|
|
### frontend/src/App.tsx
|
|
```typescript
|
|
import React, { useState } from 'react';
|
|
import Dashboard from './pages/Dashboard';
|
|
import ReportsPage from './pages/ReportsPage';
|
|
|
|
const App: React.FC = () => {
|
|
const [currentPage, setCurrentPage] = useState('dashboard');
|
|
|
|
return (
|
|
<div style={{ fontFamily: 'Arial, sans-serif' }}>
|
|
<nav style={{ backgroundColor: '#333', color: 'white', padding: '15px', display: 'flex', gap: '20px' }}>
|
|
<button
|
|
onClick={() => setCurrentPage('dashboard')}
|
|
style={{
|
|
backgroundColor: currentPage === 'dashboard' ? '#0088FE' : 'transparent',
|
|
color: 'white',
|
|
border: 'none',
|
|
padding: '10px 20px',
|
|
cursor: 'pointer',
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
Dashboard
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentPage('reports')}
|
|
style={{
|
|
backgroundColor: currentPage === 'reports' ? '#0088FE' : 'transparent',
|
|
color: 'white',
|
|
border: 'none',
|
|
padding: '10px 20px',
|
|
cursor: 'pointer',
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
Reports
|
|
</button>
|
|
</nav>
|
|
|
|
<main>
|
|
{currentPage === 'dashboard' && <Dashboard />}
|
|
{currentPage === 'reports' && <ReportsPage />}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|
|
```
|
|
|
|
### frontend/src/index.tsx
|
|
```typescript
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import App from './App';
|
|
|
|
const root = ReactDOM.createRoot(
|
|
document.getElementById('root') as HTMLElement
|
|
);
|
|
|
|
root.render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>
|
|
);
|
|
```
|
|
|
|
### frontend/public/index.html
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="theme-color" content="#000000" />
|
|
<meta
|
|
name="description"
|
|
content="Firefly III Analytics & Reporting Dashboard"
|
|
/>
|
|
<title>Firefly III Analytics</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
|
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
|
'Helvetica Neue', sans-serif;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
<div id="root"></div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
All source code is provided above. To implement:
|
|
|
|
1. Copy each section to the corresponding file path
|
|
2. Run `backend/pyproject.toml` setup
|
|
3. Run `npm install` in frontend
|
|
4. Use `docker-compose up` to deploy
|
|
|
|
Refer to INSTALLATION.md for detailed setup instructions.
|