From 82eb34c6e67846d05c7577105f7693f0c8ae25e4 Mon Sep 17 00:00:00 2001 From: ribardej Date: Wed, 22 Oct 2025 17:37:11 +0200 Subject: [PATCH 1/3] feat(frontend): improved Dashboard.tsx, added transaction date --- ...18-1f2a3c4d5e6f_add_date_to_transaction.py | 32 ++ 7project/backend/app/api/categories.py | 33 +- 7project/backend/app/api/transactions.py | 67 ++- 7project/backend/app/models/transaction.py | 3 +- 7project/backend/app/schemas/category.py | 5 + 7project/backend/app/schemas/transaction.py | 9 +- 7project/frontend/package-lock.json | 406 +++++++++++++++++- 7project/frontend/package.json | 3 +- 7project/frontend/src/api.ts | 76 +++- 7project/frontend/src/pages/BalanceChart.tsx | 46 ++ .../frontend/src/pages/CategoryPieChart.tsx | 100 +++++ 7project/frontend/src/pages/Dashboard.tsx | 189 ++++++-- 12 files changed, 921 insertions(+), 48 deletions(-) create mode 100644 7project/backend/alembic/versions/2025_10_22_1618-1f2a3c4d5e6f_add_date_to_transaction.py create mode 100644 7project/frontend/src/pages/BalanceChart.tsx create mode 100644 7project/frontend/src/pages/CategoryPieChart.tsx diff --git a/7project/backend/alembic/versions/2025_10_22_1618-1f2a3c4d5e6f_add_date_to_transaction.py b/7project/backend/alembic/versions/2025_10_22_1618-1f2a3c4d5e6f_add_date_to_transaction.py new file mode 100644 index 0000000..d28952d --- /dev/null +++ b/7project/backend/alembic/versions/2025_10_22_1618-1f2a3c4d5e6f_add_date_to_transaction.py @@ -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') diff --git a/7project/backend/app/api/categories.py b/7project/backend/app/api/categories.py index 44490f3..d2d7903 100644 --- a/7project/backend/app/api/categories.py +++ b/7project/backend/app/api/categories.py @@ -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, diff --git a/7project/backend/app/api/transactions.py b/7project/backend/app/api/transactions.py index 5fc361c..ff0c008 100644 --- a/7project/backend/app/api/transactions.py +++ b/7project/backend/app/api/transactions.py @@ -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 diff --git a/7project/backend/app/models/transaction.py b/7project/backend/app/models/transaction.py index f260400..84f3981 100644 --- a/7project/backend/app/models/transaction.py +++ b/7project/backend/app/models/transaction.py @@ -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 diff --git a/7project/backend/app/schemas/category.py b/7project/backend/app/schemas/category.py index 07fedaf..aa7064f 100644 --- a/7project/backend/app/schemas/category.py +++ b/7project/backend/app/schemas/category.py @@ -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) \ No newline at end of file diff --git a/7project/backend/app/schemas/transaction.py b/7project/backend/app/schemas/transaction.py index 9d82b4f..301f6b7 100644 --- a/7project/backend/app/schemas/transaction.py +++ b/7project/backend/app/schemas/transaction.py @@ -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) diff --git a/7project/frontend/package-lock.json b/7project/frontend/package-lock.json index f80b17f..b990f4c 100644 --- a/7project/frontend/package-lock.json +++ b/7project/frontend/package-lock.json @@ -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": { diff --git a/7project/frontend/package.json b/7project/frontend/package.json index 63e4d55..7e34963 100644 --- a/7project/frontend/package.json +++ b/7project/frontend/package.json @@ -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", diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts index 450c342..018d900 100644 --- a/7project/frontend/src/api.ts +++ b/7project/frontend/src/api.ts @@ -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 { @@ -99,8 +101,13 @@ export async function createTransaction(input: CreateTransactionInput): Promise< return res.json(); } -export async function getTransactions(): Promise { - const res = await fetch(`${getBaseUrl()}/transactions/`, { +export async function getTransactions(start_date?: string, end_date?: string): Promise { + 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 { export function logout() { localStorage.removeItem('token'); } + +// Categories +export type CreateCategoryInput = { name: string; description?: string }; +export async function createCategory(input: CreateCategoryInput): Promise { + 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 { + 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 { + 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 { + 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(); +} diff --git a/7project/frontend/src/pages/BalanceChart.tsx b/7project/frontend/src/pages/BalanceChart.tsx new file mode 100644 index 0000000..b7c1d52 --- /dev/null +++ b/7project/frontend/src/pages/BalanceChart.tsx @@ -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
No data to display
; + } + + return ( + + + + + 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 + /> + [formatAmount(value as number), 'Balance']} + /> + + + + + ); +} \ No newline at end of file diff --git a/7project/frontend/src/pages/CategoryPieChart.tsx b/7project/frontend/src/pages/CategoryPieChart.tsx new file mode 100644 index 0000000..3b9edb7 --- /dev/null +++ b/7project/frontend/src/pages/CategoryPieChart.tsx @@ -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 ( +
+

{title}

+
No data to display.
+
+ ); + } + + return ( +
+

{title}

+ + + `${name} ${(percent * 100).toFixed(0)}%`} + > + {data.map((entry, index) => ( + + ))} + + new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} /> + + + +
+ ); +} + + +export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) { + + // Calculate expenses data + const expensesData = useMemo(() => { + const spendingMap = new Map(); + + 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(); + + 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 ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index c66db24..ea8eb8b 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -1,7 +1,9 @@ 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 { BACKEND_URL } from '../config'; function formatAmount(n: number) { @@ -47,13 +49,42 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { const [filterCategoryId, setFilterCategoryId] = useState(''); const [searchText, setSearchText] = useState(''); + // Date-range filter + const [startDate, setStartDate] = useState(''); // YYYY-MM-DD + const [endDate, setEndDate] = useState(''); + + // Pagination over filtered transactions (20 per page), 0 = latest (most recent) + const pageSize = 20; + const [page, setPage] = useState(0); + + // Balance chart series for current date filter + const [balanceSeries, setBalanceSeries] = useState([]); + + // Category creation form + const [newCatName, setNewCatName] = useState(''); + const [newCatDesc, setNewCatDesc] = useState(''); + + // New transaction date + const [txDate, setTxDate] = useState(''); + + // Inline edit state for transaction categories + const [editingTxId, setEditingTxId] = useState(null); + const [editingCategoryIds, setEditingCategoryIds] = useState([]); + 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 +92,10 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { } } - useEffect(() => { loadAll(); }, []); - - const last10 = useMemo(() => { - const sorted = [...transactions].sort((a, b) => b.id - a.id); - return sorted.slice(0, 10); - }, [transactions]); + 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 +103,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 +127,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 (