Compare commits

..

11 Commits

Author SHA1 Message Date
Dejan Ribarovski
4f6d46ba7e Merge pull request #37 from dat515-2025/36-add-mock-databases-or-services-to-fetch-mocked-transactions
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Run Python Tests / build-and-test (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
feat(frontend): Added Mock Bank connection
2025-10-23 13:02:32 +02:00
Dejan Ribarovski
9fc8601e4d Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:39 +02:00
Dejan Ribarovski
e488771cc7 Update 7project/frontend/src/pages/MockBankModal.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:04 +02:00
ribardej
77992bab17 feat(docs): meeting update 2025-10-23 12:47:27 +02:00
ribardej
6972a03090 feat(frontend): Added Mock Bank connection 2025-10-23 12:08:28 +02:00
Dejan Ribarovski
6d7f834808 Merge pull request #35 from dat515-2025/34-improve-frontend-functionality
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
34 improve frontend functionality
2025-10-23 10:02:57 +02:00
ribardej
d5611e3e92 fix(frontend): Fixed type error 2025-10-23 09:57:15 +02:00
ribardej
5ecfc62b02 feat(frontend): improved UI 2025-10-23 09:22:10 +02:00
d0cbec5fca update report
Some checks are pending
Run Python Tests / build-and-test (push) Waiting to run
2025-10-22 21:52:14 +02:00
ribardej
82eb34c6e6 feat(frontend): improved Dashboard.tsx, added transaction date 2025-10-22 17:37:11 +02:00
Dejan Ribarovski
cddc1d3a9f Merge pull request #31 from dat515-2025/30-create-tests-and-set-up-a-github-pipeline
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Run Python Tests / build-and-test (push) Waiting to run
30 create tests and set up a GitHub pipeline
2025-10-22 14:59:54 +02:00
20 changed files with 1227 additions and 73 deletions

View File

@@ -0,0 +1,32 @@
"""add date to transaction
Revision ID: 1f2a3c4d5e6f
Revises: eabec90a94fe
Create Date: 2025-10-22 16:18:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision: str = '1f2a3c4d5e6f'
down_revision: Union[str, Sequence[str], None] = 'eabec90a94fe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema by adding date column with server default current_date."""
op.add_column(
'transaction',
sa.Column('date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE'))
)
def downgrade() -> None:
"""Downgrade schema by removing date column."""
op.drop_column('transaction', 'date')

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
@@ -43,6 +43,37 @@ async def list_categories(
return list(res.scalars())
@router.patch("/{category_id}", response_model=CategoryRead)
async def update_category(
category_id: int,
payload: CategoryUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# If name changed, check uniqueness per user
if payload.name is not None and payload.name != category.name:
dup = await session.execute(
select(Category.id).where(Category.user_id == user.id, Category.name == payload.name)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category.name = payload.name
if payload.description is not None:
category.description = payload.description
await session.commit()
await session.refresh(category)
return category
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,

View File

@@ -1,7 +1,8 @@
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
@@ -23,6 +24,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead:
id=tx.id,
amount=tx.amount,
description=tx.description,
date=tx.date,
category_ids=[c.id for c in (tx.categories or [])],
)
@@ -33,7 +35,21 @@ async def create_transaction(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Build transaction; set `date` only if provided to let DB default apply otherwise
tx_kwargs = dict(
amount=payload.amount,
description=payload.description,
user_id=user.id,
)
if payload.date is not None:
parsed_date = payload.date
if isinstance(parsed_date, str):
try:
parsed_date = date.fromisoformat(parsed_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx_kwargs["date"] = parsed_date
tx = Transaction(**tx_kwargs)
# Attach categories if provided (and owned by user)
if payload.category_ids:
@@ -60,11 +76,18 @@ async def create_transaction(
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
@@ -73,6 +96,36 @@ async def list_transactions(
return [_to_read_model(tx) for tx in txs]
@router.get("/balance_series")
async def get_balance_series(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Group by date and accumulate
daily = {}
for tx in txs:
key = tx.date.isoformat() if hasattr(tx.date, 'isoformat') else str(tx.date)
daily[key] = daily.get(key, 0.0) + float(tx.amount)
# Build cumulative series sorted by date
series = []
running = 0.0
for d in sorted(daily.keys()):
running += daily[d]
series.append({"date": d, "balance": running})
return series
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
@@ -111,6 +164,14 @@ async def update_transaction(
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.date is not None:
new_date = payload.date
if isinstance(new_date, str):
try:
new_date = date.fromisoformat(new_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx.date = new_date
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment

View File

@@ -1,5 +1,5 @@
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
from sqlalchemy.orm import relationship
from app.core.base import Base
from app.models.categories import association_table
@@ -10,6 +10,7 @@ class Transaction(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False)
description = Column(String(length=255), nullable=True)
date = Column(Date, nullable=False, server_default=func.current_date())
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship

View File

@@ -11,6 +11,11 @@ class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,10 +1,13 @@
from typing import List, Optional
from typing import List, Optional, Union
from datetime import date
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
@@ -12,10 +15,12 @@ class TransactionCreate(TransactionBase):
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
date: Optional[Union[date, str]]
model_config = ConfigDict(from_attributes=True)

View File

@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -1047,6 +1048,32 @@
"node": ">= 8"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.1.tgz",
"integrity": "sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
@@ -1362,6 +1389,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1407,6 +1446,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1435,7 +1537,7 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1451,6 +1553,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -1929,6 +2037,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1982,9 +2099,130 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2003,6 +2241,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2017,6 +2261,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-toolkit": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz",
"integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@@ -2260,6 +2514,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2463,6 +2723,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2490,6 +2760,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2925,6 +3204,36 @@
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2935,6 +3244,54 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3097,6 +3454,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3270,10 +3633,41 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1 +0,0 @@
/* App-level styles moved to ui.css for a cleaner layout. */

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard';
import { logout } from './api';

View File

@@ -16,6 +16,7 @@ export type Transaction = {
amount: number;
description?: string | null;
category_ids: number[];
date?: string | null; // ISO date (YYYY-MM-DD)
};
function getBaseUrl() {
@@ -84,6 +85,7 @@ export type CreateTransactionInput = {
amount: number;
description?: string;
category_ids?: number[];
date?: string; // YYYY-MM-DD
};
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
@@ -99,8 +101,13 @@ export async function createTransaction(input: CreateTransactionInput): Promise<
return res.json();
}
export async function getTransactions(): Promise<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, {
export async function getTransactions(start_date?: string, end_date?: string): Promise<Transaction[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/${qs ? `?${qs}` : ''}`;
const res = await fetch(url, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load transactions');
@@ -153,3 +160,68 @@ export async function deleteMe(): Promise<void> {
export function logout() {
localStorage.removeItem('token');
}
// Categories
export type CreateCategoryInput = { name: string; description?: string };
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/create`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to create category');
}
return res.json();
}
export type UpdateCategoryInput = { name?: string; description?: string };
export async function updateCategory(category_id: number, input: UpdateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/${category_id}`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update category');
}
return res.json();
}
// Transactions update
export type UpdateTransactionInput = {
amount?: number;
description?: string;
date?: string;
category_ids?: number[];
};
export async function updateTransaction(id: number, input: UpdateTransactionInput): Promise<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/edit`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update transaction');
}
return res.json();
}
// Balance series
export type BalancePoint = { date: string; balance: number };
export async function getBalanceSeries(start_date?: string, end_date?: string): Promise<BalancePoint[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/balance_series${qs ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: getHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to load balance series');
}
return res.json();
}

View File

@@ -14,8 +14,8 @@ export function applyFontSize(size: FontSize) {
const root = document.documentElement;
const map: Record<FontSize, string> = {
small: '14px',
medium: '16px',
large: '18px',
medium: '18px',
large: '22px',
};
root.style.fontSize = map[size];
}

View File

@@ -0,0 +1,46 @@
// src/BalanceChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type BalancePoint } from '../api';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function BalanceChart({ data }: { data: BalancePoint[] }) {
if (data.length === 0) {
return <div>No data to display</div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
// Increased 'left' margin to create more space for the Y-axis label and tick values
margin={{ top: 5, right: 30, left: 50, bottom: 5 }} // <-- Change this line
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tickFormatter={(value) => formatAmount(value as number)}
// Adjusted 'offset' for the Y-axis label.
// A negative offset moves it further away from the axis.
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }} // <-- Change this line
/>
<Tooltip
labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']}
/>
<Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,100 @@
// src/CategoryPieCharts.tsx (renamed from CategoryPieChart.tsx)
import { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type Transaction, type Category } from '../api';
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF', '#FF4242', '#8884d8', '#82ca9d'];
// Helper component for a single pie chart
function SinglePieChart({ data, title }: { data: { name: string; value: number }[]; title: string }) {
if (data.length === 0) {
return (
<div style={{ flex: 1, textAlign: 'center' }}>
<h4>{title}</h4>
<div>No data to display.</div>
</div>
);
}
return (
<div style={{ flex: 1 }}>
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
nameKey="name"
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
>
{data.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) {
// Calculate expenses data
const expensesData = useMemo(() => {
const spendingMap = new Map<number, number>();
transactions.forEach(tx => {
// Expenses are typically negative amounts in your system
if (tx.amount < 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
// Use absolute value for display on chart
spendingMap.set(catId, (spendingMap.get(catId) || 0) + Math.abs(tx.amount));
});
}
});
return Array.from(spendingMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
// Calculate earnings data
const earningsData = useMemo(() => {
const incomeMap = new Map<number, number>();
transactions.forEach(tx => {
// Earnings are typically positive amounts in your system
if (tx.amount > 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
incomeMap.set(catId, (incomeMap.get(catId) || 0) + tx.amount);
});
}
});
return Array.from(incomeMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifyContent: 'center' }}>
<SinglePieChart data={expensesData} title="Expenses by Category" />
<SinglePieChart data={earningsData} title="Earnings by Category" />
</div>
);
}

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart';
import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) {
@@ -14,6 +17,8 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
@@ -47,13 +52,42 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState('');
// Date-range filter
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
const [endDate, setEndDate] = useState<string>('');
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
const pageSize = 20;
const [page, setPage] = useState<number>(0);
// Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Category creation form
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
// New transaction date
const [txDate, setTxDate] = useState<string>('');
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
async function loadAll() {
setLoading(true);
setError(null);
try {
const [txs, cats] = await Promise.all([getTransactions(), getCategories()]);
const [txs, cats, series] = await Promise.all([
getTransactions(startDate || undefined, endDate || undefined),
getCategories(),
getBalanceSeries(startDate || undefined, endDate || undefined),
]);
setTransactions(txs);
setCategories(cats);
setBalanceSeries(series);
// reset paging to most recent
setPage(0);
} catch (err: any) {
setError(err?.message || 'Failed to load data');
} finally {
@@ -61,15 +95,54 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
}
}
useEffect(() => { loadAll(); }, []);
async function handleGenerateMockTransactions(options: MockGenerationOptions) {
setIsGenerating(true);
setMockModalOpen(false);
const last10 = useMemo(() => {
const sorted = [...transactions].sort((a, b) => b.id - a.id);
return sorted.slice(0, 10);
}, [transactions]);
const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options;
const newTransactions: Transaction[] = [];
const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
for (let i = 0; i < count; i++) {
// Generate random data based on user input
const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2));
const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime;
const date = new Date(randomTime);
const dateString = date.toISOString().split('T')[0];
const randomCategory = categoryIds.length > 0
? [categoryIds[Math.floor(Math.random() * categoryIds.length)]]
: [];
const payload = {
amount,
date: dateString,
category_ids: randomCategory,
};
try {
const created = await createTransaction(payload);
newTransactions.push(created);
} catch (err) {
console.error("Failed to create mock transaction:", err);
alert('An error occurred while generating transactions. Check the console.');
break;
}
}
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
}
useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => {
let arr = last10;
let arr = [...transactions];
const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
@@ -77,7 +150,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
return arr;
}, [last10, minAmount, maxAmount, filterCategoryId, searchText]);
}, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
const sortedDesc = useMemo(() => {
return [...filtered].sort((a, b) => {
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
if (ad !== 0) return -ad; // date desc
return b.id - a.id; // fallback id desc
});
}, [filtered]);
const totalPages = Math.ceil(sortedDesc.length / pageSize);
const pageStart = page * pageSize;
const pageEnd = pageStart + pageSize;
const visible = sortedDesc.slice(pageStart, pageEnd);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
@@ -88,16 +174,36 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
};
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId('');
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
function beginEditCategories(t: Transaction) {
setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]);
}
function cancelEditCategories() {
setEditingTxId(null);
setEditingCategoryIds([]);
}
async function saveEditCategories() {
if (editingTxId == null) return;
try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds });
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories();
} catch (err: any) {
alert(err?.message || 'Failed to update transaction categories');
}
}
return (
<div className="app-layout">
<aside className="sidebar">
@@ -119,16 +225,23 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y">
{current === 'home' && (
<>
<section className="card">
<h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p>
<button className="btn" onClick={startOauthCsas}>Connect CSAS (George)</button>
<section className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
@@ -138,9 +251,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</form>
</section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
<section className="card">
<h3>Filters</h3>
<div className="form-row">
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
@@ -152,7 +276,30 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</section>
<section className="card">
<h3>Latest Transactions (last 10)</h3>
<h3>Balance over time</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
<BalanceChart data={balanceSeries} />
)}
</section>
{/* 3. Add the new section for the Category Pie Chart */}
<section className="card">
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
// Pass the filtered transactions to see the breakdown for the current view
<CategoryPieChart transactions={filtered} categories={categories} />
)}
</section>
<section className="card">
<h3>Transactions</h3>
{loading ? (
<div>Loading</div>
) : error ? (
@@ -160,26 +307,57 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
<tr key={t.id}>
<td>{t.id}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
<>
<div className="table-controls">
<div className="muted">
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
</div>
<div className="actions">
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div>
</div>
<table className="table">
<thead>
<tr>
<th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{visible.map(t => (
<tr key={t.id}>
<td>{t.date || ''}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => {
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
setEditingCategoryIds(opts);
}}>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div>
) : (
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
</>
@@ -195,6 +373,13 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
)}
</main>
</div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
// src/MockBankModal.tsx
import { useState } from 'react';
import { type Category } from '../api';
// Define the shape of the generation options
export interface MockGenerationOptions {
count: number;
minAmount: number;
maxAmount: number;
startDate: string;
endDate: string;
categoryIds: number[];
}
interface MockBankModalProps {
isOpen: boolean;
isGenerating: boolean;
categories: Category[]; // Pass in available categories
onClose: () => void;
onGenerate: (options: MockGenerationOptions) => void;
}
export default function MockBankModal({ isOpen, isGenerating, categories, onClose, onGenerate }: MockBankModalProps) {
// State for all the new form fields
const [count, setCount] = useState('10');
const [minAmount, setMinAmount] = useState('-200');
const [maxAmount, setMaxAmount] = useState('200');
const [startDate, setStartDate] = useState(() => new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); // Default to one year ago
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); // Default to today
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
if (!isOpen) return null;
function handleGenerateClick() {
const parsedCount = parseInt(count, 10);
const parsedMinAmount = parseFloat(minAmount);
const parsedMaxAmount = parseFloat(maxAmount);
const parsedStartDate = new Date(startDate);
const parsedEndDate = new Date(endDate);
// Validation
if (
isNaN(parsedCount) || parsedCount <= 0 ||
isNaN(parsedMinAmount) || isNaN(parsedMaxAmount) ||
parsedMaxAmount < parsedMinAmount ||
isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime()) ||
parsedEndDate < parsedStartDate
) {
alert(
"Please ensure:\n" +
"- Count is a positive number\n" +
"- Min and Max Amount are valid numbers, and Max >= Min\n" +
"- Start and End Date are valid, and End Date >= Start Date"
);
return;
}
const options: MockGenerationOptions = {
count: parsedCount,
minAmount: parsedMinAmount,
maxAmount: parsedMaxAmount,
startDate,
endDate,
categoryIds: selectedCategoryIds.map(Number),
};
onGenerate(options);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Generate Mock Transactions</h3>
<p className="muted">
Customize the random transactions you'd like to import.
</p>
<div className="space-y">
<input className="input" type="number" value={count} onChange={(e) => setCount(e.target.value)} placeholder="Number of transactions" />
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="number" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} placeholder="Min amount" />
<input className="input" type="number" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="Max amount" />
</div>
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} placeholder="Earliest date" />
<input className="input" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} placeholder="Latest date" />
</div>
<select multiple className="input" style={{ height: '120px' }} value={selectedCategoryIds} onChange={(e) => setSelectedCategoryIds(Array.from(e.target.selectedOptions, option => option.value))}>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
</div>
<div className="actions" style={{ justifyContent: 'flex-end', marginTop: '16px' }}>
<button className="btn" onClick={onClose} disabled={isGenerating}>Cancel</button>
<button className="btn primary" onClick={handleGenerateClick} disabled={isGenerating}>
{isGenerating ? 'Generating...' : `Generate Transactions`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,27 +31,53 @@ body[data-theme="dark"] {
}
/* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; }
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100vh; }
.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; }
.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
.nav { margin-top: 12px; display: grid; gap: 4px; }
.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
.content { display: flex; flex-direction: column; height: 100%; }
.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.content { display: flex; flex-direction: column; overflow-y: auto; }
.topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.topbar .user { color: var(--muted); }
.page { padding: 24px; max-width: 1100px; margin: auto; }
.page { padding: 24px; }
/* Cards */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; }
.card h3 { margin: 0 0 12px; }
/* Forms */
.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); }
.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
.input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background-color: var(--panel);
color: var(--muted);
/* Add these properties specifically for the select element */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding-right: 32px; /* Add space for the custom arrow */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
cursor: pointer;
}
.input:focus, select:focus, textarea:focus {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-color: var(--primary);
}
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.form-row > * { min-width: 140px; }
.form-row > .btn {
justify-self: start;
}
.actions { display: flex; align-items: center; gap: 8px; }
/* Buttons */
@@ -59,12 +85,25 @@ body[data-theme="dark"] {
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-600); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn, .input, select, textarea, .nav a, .nav button, .segmented button {
transition: all 0.2s ease-in-out;
}
.btn.small {
padding: 4px 10px;
font-size: 0.875rem; /* 14px */
}
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); }
.table th { text-align: left; color: var(--muted); font-weight: 600; }
.table td.amount { text-align: right; font-variant-numeric: tabular-nums; }
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px; /* Adds some space above the table */
}
/* Segmented control */
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
@@ -83,3 +122,32 @@ body.auth-page #root {
/* Utility */
.muted { color: var(--muted); }
.space-y > * + * { margin-top: 12px; }
/* Modal mock bank */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--panel);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -8,7 +8,7 @@ Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-08
- Date: 2025-10-16
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
@@ -43,8 +43,8 @@ Prepare 3-5 questions and topics you want to discuss with your mentor.
Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth
- [ ] CI/CD fix
- [x] OAuth
- [x] CI/CD fix
- [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience

View File

@@ -0,0 +1,54 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-23
- Attendees: Dejan
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] OAuth (BankID)
- [x] CI/CD fix
- [X] Database local (multiple bank accounts)
- [X] Add tests and set up github pipeline
- [X] Frontend imporvment - user experience
- [ ] make the report more clear - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Improved Frontend, added Mock Bank, fixed deployment, fixed OAuth(BankID) on production, added basic tests
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
This was not prepared, I planned to do it right before meeting, but Jaychander needed to go somewhere earlier.
1. Question 1
2. Question 2
3. Question 3
## Discussion Notes (During Meeting)
The tracker should not store the transactions in the database - security vulnerability.
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [ ] Go through the checklist
- [ ] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report
---

View File

@@ -14,7 +14,7 @@
- 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**:
**Brief Description**: (něco spíš jako abstract, introuction, story behind)
Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories
and later filter by them.
@@ -338,15 +338,16 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
> Link to the specific commit on GitHub for each contribution.
### [Team Member 1 Name]
### [Lukáš]
| Date | Activity | Hours | Description |
| --------- | ------------------- | ---------- | ----------------------------------- |
| [Date] | Initial Setup | [X.X] | Repository setup, project structure |
| [Date] | Backend Development | [X.X] | Implemented user authentication |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
| Date | Activity | Hours | Description |
|----------------|---------------------|------------|----------------------------------------------------|
| 4.10 to 10.10 | Initial Setup | 40 | Repository setup, project structure, cluster setup |
| 14.10 to 16.10 | Backend Development | 12 | Implemented user authentication - oauth |
| 8.10 to 12.10 | CI/CD | 10 | Created database schema and models |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
### Dejan