first commit
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Node.js and npm
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
pnp.js
|
||||
|
||||
# React build output
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
*.log.*
|
||||
|
||||
# Dependency caches
|
||||
.pnp.*
|
||||
.eslintcache
|
||||
.parcel-cache
|
||||
.cache
|
||||
|
||||
# Editor and IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Operating system files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Miscellaneous
|
||||
temp/
|
||||
tmp/
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
147
backend/backend/alembic.ini
Normal file
147
backend/backend/alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./hoa_app.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/backend/alembic/README
Normal file
1
backend/backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
BIN
backend/backend/alembic/__pycache__/env.cpython-311.pyc
Normal file
BIN
backend/backend/alembic/__pycache__/env.cpython-311.pyc
Normal file
Binary file not shown.
80
backend/backend/alembic/env.py
Normal file
80
backend/backend/alembic/env.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'hoa_app')))
|
||||
from hoa_app.models import Base
|
||||
from hoa_app.database import engine
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/backend/alembic/script.py.mako
Normal file
28
backend/backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add reconciled to transactions
|
||||
|
||||
Revision ID: 75408262fa43
|
||||
Revises: 7d2e68fbff09
|
||||
Create Date: 2025-07-16 15:11:00.818672
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '75408262fa43'
|
||||
down_revision: Union[str, Sequence[str], None] = '7d2e68fbff09'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('transactions', sa.Column('reconciled', sa.Boolean(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('transactions', 'reconciled')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""cashflow entry projected and actual split
|
||||
|
||||
Revision ID: 7d2e68fbff09
|
||||
Revises: c1be870c3f79
|
||||
Create Date: 2025-07-16 10:25:24.891489
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7d2e68fbff09'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c1be870c3f79'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('cash_flow_entries', sa.Column('projected_amount', sa.Float(), nullable=True))
|
||||
op.add_column('cash_flow_entries', sa.Column('actual_amount', sa.Float(), nullable=True))
|
||||
op.drop_column('cash_flow_entries', 'amount')
|
||||
op.drop_column('cash_flow_entries', 'is_actual')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('cash_flow_entries', sa.Column('is_actual', sa.BOOLEAN(), nullable=True))
|
||||
op.add_column('cash_flow_entries', sa.Column('amount', sa.FLOAT(), nullable=False))
|
||||
op.drop_column('cash_flow_entries', 'actual_amount')
|
||||
op.drop_column('cash_flow_entries', 'projected_amount')
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
108
backend/backend/alembic/versions/b2d22e13e06c_initial_tables.py
Normal file
108
backend/backend/alembic/versions/b2d22e13e06c_initial_tables.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Initial tables
|
||||
|
||||
Revision ID: b2d22e13e06c
|
||||
Revises:
|
||||
Create Date: 2025-07-10 10:17:44.144159
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b2d22e13e06c'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('buckets',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_buckets_id'), 'buckets', ['id'], unique=False)
|
||||
op.create_table('roles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.Enum('admin', 'user', name='roleenum'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
|
||||
op.create_table('accounts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('bucket_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('type', sa.Enum('checking', 'savings', 'cd', 'tbill', 'other', name='accounttypeenum'), nullable=False),
|
||||
sa.Column('interest_rate', sa.Float(), nullable=True),
|
||||
sa.Column('maturity_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('balance', sa.Float(), nullable=True),
|
||||
sa.Column('last_validated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_accounts_id'), 'accounts', ['id'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sa.String(), nullable=False),
|
||||
sa.Column('role_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('cash_flows',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=True),
|
||||
sa.Column('type', sa.Enum('inflow', 'outflow', name='cashflowtypeenum'), nullable=False),
|
||||
sa.Column('estimate_actual', sa.Boolean(), nullable=True),
|
||||
sa.Column('amount', sa.Float(), nullable=False),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_cash_flows_id'), 'cash_flows', ['id'], unique=False)
|
||||
op.create_table('transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=True),
|
||||
sa.Column('type', sa.Enum('deposit', 'withdrawal', 'transfer', name='transactiontypeenum'), nullable=False),
|
||||
sa.Column('amount', sa.Float(), nullable=False),
|
||||
sa.Column('date', sa.DateTime(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('related_account_id', sa.Integer(), nullable=True),
|
||||
sa.Column('reconciled', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
|
||||
sa.ForeignKeyConstraint(['related_account_id'], ['accounts.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
|
||||
op.drop_table('transactions')
|
||||
op.drop_index(op.f('ix_cash_flows_id'), table_name='cash_flows')
|
||||
op.drop_table('cash_flows')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_accounts_id'), table_name='accounts')
|
||||
op.drop_table('accounts')
|
||||
op.drop_index(op.f('ix_roles_id'), table_name='roles')
|
||||
op.drop_table('roles')
|
||||
op.drop_index(op.f('ix_buckets_id'), table_name='buckets')
|
||||
op.drop_table('buckets')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""cashflow entry projected and actual split
|
||||
|
||||
Revision ID: c1be870c3f79
|
||||
Revises: b2d22e13e06c
|
||||
Create Date: 2025-07-16 10:24:38.744257
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c1be870c3f79'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b2d22e13e06c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
245
backend/backend/forecast_operating_2025_2026.json
Normal file
245
backend/backend/forecast_operating_2025_2026.json
Normal file
@@ -0,0 +1,245 @@
|
||||
[
|
||||
{
|
||||
"account_id": 1,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 21217.22
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 20643.65
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 61910.86
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 121869.26
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 116787.51
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 97474.75
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 28876.15
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 19355.15
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 19334.15
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 19313.15
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": 19292.15
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": 9771.150000000001
|
||||
},
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 4130.810000000001
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 42964.850000000006
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 101023.85
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 100182.74
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 92845.96
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 32192.390000000007
|
||||
}
|
||||
],
|
||||
"is_primary": true,
|
||||
"alert": false,
|
||||
"account_name": "Checking 5345",
|
||||
"account_type": "Checking"
|
||||
},
|
||||
{
|
||||
"account_id": 2,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 30000
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 30096.25
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 30192.808802083335
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 30289.67739699002
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": 30386.856778638696
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": 30484.34794413683
|
||||
},
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 30096.25
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "CD - 6mo Op",
|
||||
"account_type": "CD"
|
||||
},
|
||||
{
|
||||
"account_id": 5,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 30000
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 30050
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 20584.25
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 11102.72375
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": 1605.3949562499993
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": 1608.070614510416
|
||||
},
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 1610.7507322012666
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 1613.4353167549355
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 1616.1243756161937
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 1618.8179162422207
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 1621.5159461026244
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 31674.218472679462
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "Money Market Operating",
|
||||
"account_type": "Money Market"
|
||||
}
|
||||
]
|
||||
23
backend/backend/frontend/.gitignore
vendored
Normal file
23
backend/backend/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
173
backend/backend/frontend/forecast_2026.json
Normal file
173
backend/backend/frontend/forecast_2026.json
Normal file
@@ -0,0 +1,173 @@
|
||||
[
|
||||
{
|
||||
"account_id": 1,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-11-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-12-01",
|
||||
"balance": 0
|
||||
}
|
||||
],
|
||||
"is_primary": true,
|
||||
"alert": true,
|
||||
"account_name": "Checking 5345",
|
||||
"account_type": "Checking"
|
||||
},
|
||||
{
|
||||
"account_id": 2,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-11-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-12-01",
|
||||
"balance": 0
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "CD - 6mo Op",
|
||||
"account_type": "CD"
|
||||
},
|
||||
{
|
||||
"account_id": 5,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-09-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-11-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2026-12-01",
|
||||
"balance": 0
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "Money Market Operating",
|
||||
"account_type": "Money Market"
|
||||
}
|
||||
]
|
||||
173
backend/backend/frontend/forecast_operating.json
Normal file
173
backend/backend/frontend/forecast_operating.json
Normal file
@@ -0,0 +1,173 @@
|
||||
[
|
||||
{
|
||||
"account_id": 1,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 21217.22
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 20643.65
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 61910.86
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 121869.26
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 116787.51
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 97474.75
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 28876.15
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 19355.15
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 9834.150000000001
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 313.15000000000146
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": -9207.849999999999
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": -18728.85
|
||||
}
|
||||
],
|
||||
"is_primary": true,
|
||||
"alert": true,
|
||||
"account_name": "Checking 5345",
|
||||
"account_type": "Checking"
|
||||
},
|
||||
{
|
||||
"account_id": 2,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 30000
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 30096.25
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 30192.808802083335
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 30289.67739699002
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": 30386.856778638696
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": 30484.34794413683
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "CD - 6mo Op",
|
||||
"account_type": "CD"
|
||||
},
|
||||
{
|
||||
"account_id": 5,
|
||||
"forecast": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-02-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-03-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-04-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-05-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"balance": 0
|
||||
},
|
||||
{
|
||||
"date": "2025-07-01",
|
||||
"balance": 30000
|
||||
},
|
||||
{
|
||||
"date": "2025-08-01",
|
||||
"balance": 30050
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"balance": 30100.083333333332
|
||||
},
|
||||
{
|
||||
"date": "2025-10-01",
|
||||
"balance": 30150.25013888889
|
||||
},
|
||||
{
|
||||
"date": "2025-11-01",
|
||||
"balance": 30200.500555787035
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"balance": 30250.834723380012
|
||||
}
|
||||
],
|
||||
"is_primary": false,
|
||||
"alert": false,
|
||||
"account_name": "Money Market Operating",
|
||||
"account_type": "Money Market"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"detail": "Backend service unavailable. Please check the logs for more information."
|
||||
}
|
||||
61
backend/backend/frontend/package.json
Normal file
61
backend/backend/frontend/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.2.0",
|
||||
"@mui/material": "^7.2.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"axios": "^1.10.0",
|
||||
"cra-template-pwa-typescript": "2.0.0",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"plotly.js": "^3.0.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^7.6.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.1.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/plotly.js": "^3.0.2",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/testing-library__react": "^10.2.0",
|
||||
"jest": "^30.0.4",
|
||||
"ts-jest": "^29.4.0"
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
BIN
backend/backend/frontend/public/favicon.ico
Normal file
BIN
backend/backend/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
1
backend/backend/frontend/public/images/hoapro-logo.svg
Normal file
1
backend/backend/frontend/public/images/hoapro-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
40
backend/backend/frontend/public/index.html
Normal file
40
backend/backend/frontend/public/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>HOA Pro</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
backend/backend/frontend/public/logo192.png
Normal file
BIN
backend/backend/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
backend/backend/frontend/public/logo512.png
Normal file
BIN
backend/backend/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
backend/backend/frontend/public/manifest.json
Normal file
25
backend/backend/frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
backend/backend/frontend/public/robots.txt
Normal file
3
backend/backend/frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
147
backend/backend/frontend/src/AccountOverview.tsx
Normal file
147
backend/backend/frontend/src/AccountOverview.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { fetchAccount, fetchAccountHistory } from './api';
|
||||
import { Box, Typography, Paper, Button, Grid, CircularProgress, useTheme } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
function formatLocalDate(dateString: string | null | undefined) {
|
||||
if (!dateString) return '-';
|
||||
const d = new Date(dateString);
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Helper to calculate interest to maturity or annualized, with monthly compounding
|
||||
function calculateInterest(account: any) {
|
||||
if (!account.interest_rate || !account.balance) return null;
|
||||
const rate = account.interest_rate / 100;
|
||||
const balance = account.balance;
|
||||
const n = 12; // monthly compounding
|
||||
if (account.maturity_date) {
|
||||
// Calculate months from now to maturity
|
||||
const now = new Date();
|
||||
const maturity = new Date(account.maturity_date);
|
||||
const msPerMonth = 1000 * 60 * 60 * 24 * 30.4375; // average days per month
|
||||
const months = Math.max(0, Math.round((maturity.getTime() - now.getTime()) / msPerMonth));
|
||||
const years = months / 12;
|
||||
// Compound interest formula: A = P * (1 + r/n)^(n*t)
|
||||
const amount = balance * Math.pow(1 + rate / n, n * years);
|
||||
const interest = amount - balance;
|
||||
return {
|
||||
label: `Est. Interest to Maturity (${months} months, compounded monthly)`,
|
||||
value: interest,
|
||||
};
|
||||
} else {
|
||||
// Annualized, compounded monthly for 1 year
|
||||
const amount = balance * Math.pow(1 + rate / n, n * 1);
|
||||
const interest = amount - balance;
|
||||
return {
|
||||
label: 'Est. Annual Interest (compounded monthly)',
|
||||
value: interest,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const AccountOverview: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState<any>(null);
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const acc = await fetchAccount(Number(id));
|
||||
const hist = await fetchAccountHistory(Number(id));
|
||||
setAccount(acc);
|
||||
setHistory(hist);
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh"><CircularProgress /></Box>;
|
||||
if (error) return <Box color="error.main">{error}</Box>;
|
||||
if (!account) return null;
|
||||
|
||||
// Prepare chart data: show only date (no time)
|
||||
const chartData = history.map((h: any) => ({
|
||||
...h,
|
||||
date: formatLocalDate(h.date),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(-1)} sx={{ mb: 2 }}>
|
||||
Back to Accounts
|
||||
</Button>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Account Overview - {account.name}
|
||||
</Typography>
|
||||
<Box display="flex" gap={2} mb={3}>
|
||||
{/* Left: Balance */}
|
||||
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>Balance</Typography>
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 700 }}>
|
||||
{account.balance?.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{/* Center: Institution, Type, Bucket (values only) */}
|
||||
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 500 }}>{account.institution_name}</Typography>
|
||||
<Typography variant="body1">{account.account_type?.name || account.type}</Typography>
|
||||
<Typography variant="body1">{account.bucket?.name}</Typography>
|
||||
</Paper>
|
||||
{/* Right: Interest Rate, Maturity Date, Estimated Interest */}
|
||||
<Paper elevation={3} sx={{ flex: 1, p: 3, borderRadius: 3, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>Interest Rate</Typography>
|
||||
<Typography variant="h6">{account.interest_rate ? `${account.interest_rate}%` : '-'}</Typography>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom mt={2}>Maturity Date</Typography>
|
||||
<Typography variant="body1">{formatLocalDate(account.maturity_date)}</Typography>
|
||||
{/* Estimated Interest */}
|
||||
{(() => {
|
||||
const interest = calculateInterest(account);
|
||||
return interest ? (
|
||||
<Box mt={2} textAlign="center">
|
||||
<Typography variant="subtitle2" color="textSecondary">{interest.label}</Typography>
|
||||
<Typography variant="body1" color="success.main" sx={{ fontWeight: 600 }}>
|
||||
{interest.value.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null;
|
||||
})()}
|
||||
</Paper>
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Last data update: {formatLocalDate(account.last_validated_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box mt={4}>
|
||||
<Typography variant="h6" gutterBottom>Balance Over Time</Typography>
|
||||
<Paper elevation={2} sx={{ p: 2, borderRadius: 2, background: theme.palette.background.default }}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData} margin={{ top: 16, right: 32, left: 32, bottom: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tickFormatter={d => d} minTickGap={20} />
|
||||
<YAxis width={90} tickFormatter={v => v.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })} />
|
||||
<Tooltip formatter={(v: number) => v.toLocaleString(undefined, { style: 'currency', currency: 'USD' })} labelFormatter={d => d} />
|
||||
<Line type="monotone" dataKey="balance" stroke={theme.palette.primary.main} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountOverview;
|
||||
279
backend/backend/frontend/src/Accounts.tsx
Normal file
279
backend/backend/frontend/src/Accounts.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, IconButton } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import MonetizationOnIcon from '@mui/icons-material/MonetizationOn';
|
||||
import { fetchAccounts, createAccount, deleteAccount, fetchBuckets, fetchAccountTypes, updateAccount } from './api';
|
||||
import { AuthContext } from './App';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Accounts: React.FC = () => {
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [buckets, setBuckets] = useState<any[]>([]);
|
||||
const [accountTypes, setAccountTypes] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
institution_name: '',
|
||||
account_type_id: '',
|
||||
interest_rate: 0,
|
||||
maturity_date: '',
|
||||
balance: 0,
|
||||
bucket_id: '',
|
||||
});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<any>(null);
|
||||
const [balanceOpen, setBalanceOpen] = useState(false);
|
||||
const [balanceForm, setBalanceForm] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<any>(null);
|
||||
const { user } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = async () => {
|
||||
setAccounts(await fetchAccounts());
|
||||
setBuckets(await fetchBuckets());
|
||||
setAccountTypes(await fetchAccountTypes());
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const payload = {
|
||||
...form,
|
||||
interest_rate: parseFloat(form.interest_rate as any),
|
||||
balance: parseFloat(form.balance as any),
|
||||
bucket_id: parseInt(form.bucket_id),
|
||||
account_type_id: parseInt(form.account_type_id),
|
||||
maturity_date: form.maturity_date === '' ? null : form.maturity_date,
|
||||
};
|
||||
await createAccount(payload);
|
||||
setOpen(false);
|
||||
setForm({ name: '', institution_name: '', account_type_id: '', interest_rate: 0, maturity_date: '', balance: 0, bucket_id: '' });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (acc: any) => {
|
||||
setDeleteTarget(acc);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deleteTarget) {
|
||||
await deleteAccount(deleteTarget.id);
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
}
|
||||
};
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleEditOpen = (acc: any) => {
|
||||
setEditForm({
|
||||
...acc,
|
||||
account_type_id: acc.account_type?.id || '',
|
||||
bucket_id: acc.bucket?.id || '',
|
||||
maturity_date: acc.maturity_date ? acc.maturity_date.substring(0, 10) : '',
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
const handleEditClose = () => setEditOpen(false);
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditForm({ ...editForm, [e.target.name]: e.target.value });
|
||||
};
|
||||
const handleEditSubmit = async () => {
|
||||
const payload = {
|
||||
...editForm,
|
||||
interest_rate: parseFloat(editForm.interest_rate as any),
|
||||
balance: parseFloat(editForm.balance as any),
|
||||
bucket_id: parseInt(editForm.bucket_id),
|
||||
account_type_id: parseInt(editForm.account_type_id),
|
||||
maturity_date: editForm.maturity_date === '' ? null : editForm.maturity_date,
|
||||
};
|
||||
await updateAccount(editForm.id, payload);
|
||||
setEditOpen(false);
|
||||
setEditForm(null);
|
||||
load();
|
||||
};
|
||||
const handleBalanceOpen = (acc: any) => {
|
||||
setBalanceForm({ id: acc.id, balance: acc.balance });
|
||||
setBalanceOpen(true);
|
||||
};
|
||||
const handleBalanceClose = () => setBalanceOpen(false);
|
||||
const handleBalanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBalanceForm({ ...balanceForm, balance: e.target.value });
|
||||
};
|
||||
const handleBalanceSubmit = async () => {
|
||||
await updateAccount(balanceForm.id, { balance: parseFloat(balanceForm.balance) });
|
||||
setBalanceOpen(false);
|
||||
setBalanceForm(null);
|
||||
load();
|
||||
};
|
||||
|
||||
// Group accounts by bucket
|
||||
const grouped = buckets.map(bucket => ({
|
||||
...bucket,
|
||||
accounts: accounts.filter(acc => acc.bucket && acc.bucket.id === bucket.id)
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h5">Accounts</Typography>
|
||||
{user?.role === 'admin' && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Account</Button>
|
||||
)}
|
||||
</Box>
|
||||
{grouped.map(group => {
|
||||
// Calculate total balance for this bucket
|
||||
const totalBalance = group.accounts.reduce((sum: number, acc: any) => sum + (acc.balance || 0), 0);
|
||||
return (
|
||||
<Box key={group.id} mb={4}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Typography variant="h6" gutterBottom>{group.name}</Typography>
|
||||
<Typography variant="subtitle1" color="primary" sx={{ fontWeight: 600, background: '#f5f5f5', borderRadius: 2, px: 2, py: 0.5 }}>
|
||||
{totalBalance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Institution</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Interest Rate (%)</TableCell>
|
||||
<TableCell>Maturity Date</TableCell>
|
||||
<TableCell>Balance</TableCell>
|
||||
<TableCell>Last Verified</TableCell>
|
||||
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{group.accounts.map((acc: any) => (
|
||||
<TableRow key={acc.id} hover style={{ cursor: 'pointer' }} onClick={() => navigate(`/accounts/${acc.id}`)}>
|
||||
<TableCell>{acc.name}</TableCell>
|
||||
<TableCell>{acc.institution_name}</TableCell>
|
||||
<TableCell>{acc.account_type?.name}</TableCell>
|
||||
<TableCell>{acc.interest_rate}</TableCell>
|
||||
<TableCell>{acc.maturity_date ? acc.maturity_date.substring(0, 10) : ''}</TableCell>
|
||||
<TableCell>{acc.balance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell>{acc.last_validated_at ? acc.last_validated_at.substring(0, 19).replace('T', ' ') : ''}</TableCell>
|
||||
{user?.role === 'admin' && (
|
||||
<TableCell>
|
||||
<IconButton aria-label="update-balance" onClick={e => { e.stopPropagation(); handleBalanceOpen(acc); }}>
|
||||
<MonetizationOnIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="edit" onClick={e => { e.stopPropagation(); handleEditOpen(acc); }}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="delete" color="error" onClick={e => { e.stopPropagation(); handleDeleteClick(acc); }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Add Account</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Name" name="name" value={form.name} onChange={handleChange} fullWidth margin="normal" required />
|
||||
<TextField label="Institution Name" name="institution_name" value={form.institution_name} onChange={handleChange} fullWidth margin="normal" required />
|
||||
<TextField select label="Type" name="account_type_id" value={form.account_type_id} onChange={handleChange} fullWidth margin="normal" required>
|
||||
{accountTypes.map((opt: any) => <MenuItem key={opt.id} value={opt.id}>{opt.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select label="Bucket" name="bucket_id" value={form.bucket_id} onChange={handleChange} fullWidth margin="normal" required>
|
||||
{buckets.map(b => <MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField label="Interest Rate (%)" name="interest_rate" type="number" value={form.interest_rate} onChange={handleChange} fullWidth margin="normal" />
|
||||
{/* Show Maturity Date only if selected account type is CD */}
|
||||
{(() => {
|
||||
const selectedType = accountTypes.find((t: any) => String(t.id) === String(form.account_type_id));
|
||||
if (selectedType && selectedType.name === 'CD') {
|
||||
return (
|
||||
<TextField label="Maturity Date" name="maturity_date" type="date" value={form.maturity_date} onChange={handleChange} fullWidth margin="normal" InputLabelProps={{ shrink: true }} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<TextField label="Balance" name="balance" type="number" value={form.balance} onChange={handleChange} fullWidth margin="normal" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained">Add</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={editOpen} onClose={handleEditClose}>
|
||||
<DialogTitle>Edit Account</DialogTitle>
|
||||
<DialogContent>
|
||||
{editForm && <>
|
||||
<TextField label="Name" name="name" value={editForm.name} onChange={handleEditChange} fullWidth margin="normal" required />
|
||||
<TextField label="Institution Name" name="institution_name" value={editForm.institution_name} onChange={handleEditChange} fullWidth margin="normal" required />
|
||||
<TextField select label="Type" name="account_type_id" value={editForm.account_type_id} onChange={handleEditChange} fullWidth margin="normal" required>
|
||||
{accountTypes.map((opt: any) => <MenuItem key={opt.id} value={opt.id}>{opt.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select label="Bucket" name="bucket_id" value={editForm.bucket_id} onChange={handleEditChange} fullWidth margin="normal" required>
|
||||
{buckets.map(b => <MenuItem key={b.id} value={b.id}>{b.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField label="Interest Rate (%)" name="interest_rate" type="number" value={editForm.interest_rate} onChange={handleEditChange} fullWidth margin="normal" />
|
||||
{/* Show Maturity Date only if selected account type is CD */}
|
||||
{(() => {
|
||||
const selectedType = accountTypes.find((t: any) => String(t.id) === String(editForm.account_type_id));
|
||||
if (selectedType && selectedType.name === 'CD') {
|
||||
return (
|
||||
<TextField label="Maturity Date" name="maturity_date" type="date" value={editForm.maturity_date} onChange={handleEditChange} fullWidth margin="normal" InputLabelProps={{ shrink: true }} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<TextField label="Balance" name="balance" type="number" value={editForm.balance} onChange={handleEditChange} fullWidth margin="normal" />
|
||||
</>}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleEditClose}>Cancel</Button>
|
||||
<Button onClick={handleEditSubmit} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={balanceOpen} onClose={handleBalanceClose}>
|
||||
<DialogTitle>Update Balance</DialogTitle>
|
||||
<DialogContent>
|
||||
{balanceForm && <TextField label="Balance" name="balance" type="number" value={balanceForm.balance} onChange={handleBalanceChange} fullWidth margin="normal" />}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleBalanceClose}>Cancel</Button>
|
||||
<Button onClick={handleBalanceSubmit} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={deleteConfirmOpen} onClose={handleDeleteCancel}>
|
||||
<DialogTitle>Confirm Account Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete the account "{deleteTarget?.name}"? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error">Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accounts;
|
||||
38
backend/backend/frontend/src/App.css
Normal file
38
backend/backend/frontend/src/App.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
9
backend/backend/frontend/src/App.test.tsx
Normal file
9
backend/backend/frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
265
backend/backend/frontend/src/App.tsx
Normal file
265
backend/backend/frontend/src/App.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Link as RouterLink } from 'react-router-dom';
|
||||
import { AppBar, Toolbar, Typography, CssBaseline, Drawer, List, ListItem, ListItemText, IconButton, Box, Button, Link, Avatar, Menu, MenuItem, Divider } from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||
import { getTheme } from './theme';
|
||||
import Login from './Login';
|
||||
import { getUser, fetchUser, logout } from './auth';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import HelpIcon from '@mui/icons-material/Help';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Accounts from './Accounts';
|
||||
import AccountOverview from './AccountOverview';
|
||||
import CashFlows from './CashFlows';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Forecasting from './Forecasting';
|
||||
import Transactions from './Transactions';
|
||||
|
||||
export const AuthContext = React.createContext<{ user: any, setUser: (u: any) => void }>({ user: null, setUser: () => {} });
|
||||
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user } = React.useContext(AuthContext);
|
||||
if (!user) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Placeholder components for pages
|
||||
const Dashboard = () => <div>Dashboard</div>;
|
||||
const Reports = () => <div>Reports</div>;
|
||||
|
||||
const drawerWidth = 220;
|
||||
|
||||
function AppContent({ mode, handleThemeToggle, handleLogout, user, setUser, navLinks }: any) {
|
||||
const location = useLocation();
|
||||
const isLoginPage = window.location.pathname === '/login';
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleProfileClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleProfileClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CssBaseline />
|
||||
{isLoginPage ? (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="static" color="default" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/dashboard"
|
||||
underline="none"
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<img
|
||||
src="/images/hoapro-logo.svg"
|
||||
alt="HOA Pro"
|
||||
style={{
|
||||
height: '40px',
|
||||
width: 'auto',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
{navLinks.map((link: any) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
component={RouterLink}
|
||||
to={link.to}
|
||||
underline="none"
|
||||
sx={{
|
||||
color: location.pathname.startsWith(link.to) ? 'primary.main' : 'text.primary',
|
||||
fontWeight: location.pathname.startsWith(link.to) ? 'bold' : 'normal',
|
||||
borderBottom: location.pathname.startsWith(link.to) ? '2px solid' : '2px solid transparent',
|
||||
borderColor: location.pathname.startsWith(link.to) ? 'primary.main' : 'transparent',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
fontSize: '1rem',
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton color="inherit" onClick={handleThemeToggle} size="small">
|
||||
{mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
||||
<Avatar
|
||||
onClick={handleProfileClick}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
cursor: 'pointer',
|
||||
bgcolor: 'primary.main',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{user?.username?.charAt(0).toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleProfileClick}
|
||||
sx={{ ml: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleProfileClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 200,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
|
||||
borderRadius: 2,
|
||||
}
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem sx={{ py: 1, px: 2, color: 'text.secondary', fontSize: '0.875rem' }}>
|
||||
Signed in as {user?.username || 'user'}
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<PersonIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Profile
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<StarIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Starred
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<NotificationsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Subscriptions
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<SettingsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<HelpIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Help
|
||||
</MenuItem>
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleProfileClose} sx={{ py: 1.5, px: 2 }}>
|
||||
<AdminPanelSettingsIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Site Administration
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleProfileClose();
|
||||
handleLogout();
|
||||
}}
|
||||
sx={{ py: 1.5, px: 2 }}
|
||||
>
|
||||
<LogoutIcon sx={{ mr: 2, fontSize: '1.25rem' }} />
|
||||
Sign Out
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/accounts" element={<ProtectedRoute><Accounts /></ProtectedRoute>} />
|
||||
<Route path="/accounts/:id" element={<ProtectedRoute><AccountOverview /></ProtectedRoute>} />
|
||||
<Route path="/transactions" element={<ProtectedRoute><Transactions /></ProtectedRoute>} />
|
||||
<Route path="/cashflows" element={<ProtectedRoute><CashFlows /></ProtectedRoute>} />
|
||||
<Route path="/forecasting" element={<ProtectedRoute><Forecasting /></ProtectedRoute>} />
|
||||
<Route path="/reports" element={<ProtectedRoute><Reports /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mode, setMode] = useState<'light' | 'dark'>(
|
||||
() => (localStorage.getItem('themeMode') as 'light' | 'dark') || 'light'
|
||||
);
|
||||
const [user, setUser] = React.useState<any>(getUser());
|
||||
const navLinks = [
|
||||
{ label: 'Dashboard', to: '/dashboard' },
|
||||
{ label: 'Accounts', to: '/accounts' },
|
||||
{ label: 'Transactions', to: '/transactions' },
|
||||
{ label: 'Cash Flows', to: '/cashflows' },
|
||||
{ label: 'Forecasting', to: '/forecasting' },
|
||||
{ label: 'Reports', to: '/reports' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
fetchUser().then(setUser);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const newMode = mode === 'light' ? 'dark' : 'light';
|
||||
setMode(newMode);
|
||||
localStorage.setItem('themeMode', newMode);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setUser(null);
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={getTheme(mode)}>
|
||||
<AuthContext.Provider value={{ user, setUser }}>
|
||||
<Router>
|
||||
<AppContent
|
||||
mode={mode}
|
||||
handleThemeToggle={handleThemeToggle}
|
||||
handleLogout={handleLogout}
|
||||
user={user}
|
||||
setUser={setUser}
|
||||
navLinks={navLinks}
|
||||
/>
|
||||
</Router>
|
||||
</AuthContext.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
85
backend/backend/frontend/src/Buckets.tsx
Normal file
85
backend/backend/frontend/src/Buckets.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { fetchBuckets, createBucket, deleteBucket } from './api';
|
||||
import { AuthContext } from './App';
|
||||
|
||||
const Buckets: React.FC = () => {
|
||||
const [buckets, setBuckets] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const load = async () => {
|
||||
setBuckets(await fetchBuckets());
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await createBucket(form);
|
||||
setOpen(false);
|
||||
setForm({ name: '', description: '' });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteBucket(id);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h5">Buckets</Typography>
|
||||
{user?.role === 'admin' && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Bucket</Button>
|
||||
)}
|
||||
</Box>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{buckets.map(bucket => (
|
||||
<TableRow key={bucket.id}>
|
||||
<TableCell>{bucket.name}</TableCell>
|
||||
<TableCell>{bucket.description}</TableCell>
|
||||
{user?.role === 'admin' && (
|
||||
<TableCell>
|
||||
<IconButton color="error" onClick={() => handleDelete(bucket.id)}><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Add Bucket</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField label="Name" name="name" value={form.name} onChange={handleChange} fullWidth margin="normal" required />
|
||||
<TextField label="Description" name="description" value={form.description} onChange={handleChange} fullWidth margin="normal" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained">Add</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Buckets;
|
||||
376
backend/backend/frontend/src/CashFlows.tsx
Normal file
376
backend/backend/frontend/src/CashFlows.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, TableFooter } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { fetchCashFlowCategories, fetchCashFlowEntries, createCashFlowEntry, updateCashFlowEntry, deleteCashFlowEntry } from './api';
|
||||
import { AuthContext } from './App';
|
||||
|
||||
const MONTHS = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
|
||||
const CashFlows: React.FC = () => {
|
||||
const { user } = useContext(AuthContext);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [year, setYear] = useState<number>(new Date().getFullYear());
|
||||
const [edit, setEdit] = useState<{category: any, month: number, entry: any} | null>(null);
|
||||
const [form, setForm] = useState<{projected_amount: number | string, actual_amount: number | string}>({ projected_amount: '', actual_amount: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [bulkDialog, setBulkDialog] = useState<{open: boolean, category: any | null, value: string}>({ open: false, category: null, value: '' });
|
||||
const [confirmDialog, setConfirmDialog] = useState<{open: boolean, onConfirm: (() => void) | null}>({ open: false, onConfirm: null });
|
||||
const [operatingStartBalance, setOperatingStartBalance] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const cats = await fetchCashFlowCategories();
|
||||
const ents = await fetchCashFlowEntries(year);
|
||||
setCategories(cats);
|
||||
setEntries(ents);
|
||||
setLoading(false);
|
||||
// Debug logging
|
||||
console.debug('CASHFLOW: Loaded categories', cats);
|
||||
console.debug('CASHFLOW: Loaded entries', ents);
|
||||
// Fetch current operating account balance for starting point
|
||||
try {
|
||||
const resp = await fetch('/accounts');
|
||||
const accounts = await resp.json();
|
||||
// Sum balances of all operating accounts
|
||||
const opAccounts = accounts.filter((a: any) => a.funding_type === 'Operating');
|
||||
const opBalance = opAccounts.reduce((sum: number, a: any) => sum + (a.balance || 0), 0);
|
||||
setOperatingStartBalance(opBalance);
|
||||
console.debug('CASHFLOW: Operating start balance', opBalance);
|
||||
// Set warning for projected operating balance
|
||||
let runningBalance = opBalance;
|
||||
let minMonth = null;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
let income = 0, expense = 0;
|
||||
for (const cat of cats) {
|
||||
const entry = ents.find((e: any) => e.category_id === cat.id && e.month === m);
|
||||
if (cat.funding_type === 'Operating') {
|
||||
if (cat.category_type === 'income') income += entry?.projected_amount || 0;
|
||||
if (cat.category_type === 'expense') expense += entry?.projected_amount || 0;
|
||||
}
|
||||
}
|
||||
runningBalance += income - expense;
|
||||
if (runningBalance < 1000 && !minMonth) minMonth = m;
|
||||
}
|
||||
setWarning(minMonth ? `Warning: Projected operating balance falls below $1,000 in ${MONTHS[minMonth-1]}` : null);
|
||||
} catch (e) {
|
||||
setOperatingStartBalance(null);
|
||||
setWarning(null);
|
||||
console.error('CASHFLOW: Failed to fetch operating account balance', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [year]);
|
||||
|
||||
// Calculate projected operating balance and minMonth using useMemo for explanation
|
||||
const { opBalance, minMonth } = useMemo(() => {
|
||||
if (operatingStartBalance == null) return { opBalance: null, minMonth: null };
|
||||
let runningBalance = operatingStartBalance;
|
||||
let minMonth = null;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
let income = 0, expense = 0;
|
||||
for (const cat of categories) {
|
||||
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === m);
|
||||
if (cat.funding_type === 'Operating') {
|
||||
if (cat.category_type === 'income') income += entry?.projected_amount || 0;
|
||||
if (cat.category_type === 'expense') expense += entry?.projected_amount || 0;
|
||||
}
|
||||
}
|
||||
runningBalance += income - expense;
|
||||
if (runningBalance < 1000 && !minMonth) minMonth = m;
|
||||
}
|
||||
return { opBalance: runningBalance, minMonth };
|
||||
}, [categories, entries, year, operatingStartBalance]);
|
||||
|
||||
const handleCellClick = (cat: any, month: number) => {
|
||||
if (user?.role !== 'admin') return;
|
||||
const entry = entries.find(e => e.category_id === cat.id && e.month === month);
|
||||
setEdit({ category: cat, month, entry });
|
||||
setForm({
|
||||
projected_amount: (typeof entry?.projected_amount === 'number' && entry.projected_amount === 0) ? 0 : (entry?.projected_amount ?? ''),
|
||||
actual_amount: (typeof entry?.actual_amount === 'number' && entry.actual_amount === 0) ? 0 : (entry?.actual_amount ?? '')
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!edit) return;
|
||||
const payload = {
|
||||
category_id: edit.category.id,
|
||||
year,
|
||||
month: edit.month,
|
||||
projected_amount: parseFloat(form.projected_amount as any),
|
||||
actual_amount: parseFloat(form.actual_amount as any),
|
||||
};
|
||||
if (edit.entry) {
|
||||
await updateCashFlowEntry(edit.entry.id, payload);
|
||||
console.debug('CASHFLOW: Updated entry', payload);
|
||||
} else {
|
||||
await createCashFlowEntry(payload);
|
||||
console.debug('CASHFLOW: Created entry', payload);
|
||||
}
|
||||
setEdit(null);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (edit?.entry) {
|
||||
await deleteCashFlowEntry(edit.entry.id);
|
||||
console.debug('CASHFLOW: Deleted entry', edit.entry.id);
|
||||
setEdit(null);
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkClick = (cat: any) => {
|
||||
setBulkDialog({ open: true, category: cat, value: '' });
|
||||
};
|
||||
const handleBulkValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBulkDialog(b => ({ ...b, value: e.target.value }));
|
||||
};
|
||||
const handleBulkSubmit = () => {
|
||||
setConfirmDialog({ open: true, onConfirm: async () => {
|
||||
setConfirmDialog({ open: false, onConfirm: null });
|
||||
setBulkDialog(b => ({ ...b, open: false }));
|
||||
// For each month, create or update the entry for this category and year
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const entry = entries.find(e => e.category_id === bulkDialog.category.id && e.month === m);
|
||||
const payload = {
|
||||
category_id: bulkDialog.category.id,
|
||||
year,
|
||||
month: m,
|
||||
projected_amount: parseFloat(bulkDialog.value),
|
||||
actual_amount: entry?.actual_amount || null,
|
||||
};
|
||||
if (entry) {
|
||||
await updateCashFlowEntry(entry.id, payload);
|
||||
} else {
|
||||
await createCashFlowEntry(payload);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}});
|
||||
};
|
||||
|
||||
// Group categories by funding type and type
|
||||
const grouped = categories.reduce((acc: any, cat: any) => {
|
||||
if (!acc[cat.funding_type]) acc[cat.funding_type] = { income: [], expense: [] };
|
||||
acc[cat.funding_type][cat.category_type].push(cat);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Add this helper function inside the CashFlows component:
|
||||
function getTotalsForGroup(group: any[], entries: any[]) {
|
||||
const monthTotals = Array(12).fill(0);
|
||||
let actualTotal = 0, budgetTotal = 0;
|
||||
group.forEach((cat: any) => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === i + 1);
|
||||
if (typeof entry?.actual_amount === 'number' && entry.actual_amount !== null) {
|
||||
monthTotals[i] += entry.actual_amount;
|
||||
actualTotal += entry.actual_amount;
|
||||
budgetTotal += entry.actual_amount;
|
||||
} else if (typeof entry?.projected_amount === 'number' && entry.projected_amount !== null) {
|
||||
monthTotals[i] += entry.projected_amount;
|
||||
budgetTotal += entry.projected_amount;
|
||||
}
|
||||
}
|
||||
});
|
||||
const varianceTotal = budgetTotal - actualTotal;
|
||||
return { monthTotals, actualTotal, budgetTotal, varianceTotal };
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h5">Cash Flow Forecast ({year})</Typography>
|
||||
<Box>
|
||||
<Button onClick={() => setYear(y => y - 1)}>-</Button>
|
||||
<Button onClick={() => setYear(y => y + 1)}>+</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{warning && <Typography color="error" mb={1}>{warning}</Typography>}
|
||||
{warning && typeof minMonth === 'number' && minMonth >= 1 && (
|
||||
<Typography variant="body2" color="textSecondary" mb={2}>
|
||||
The projected operating balance in {MONTHS[minMonth-1]} is ${opBalance.toLocaleString()}.
|
||||
</Typography>
|
||||
)}
|
||||
{loading ? <Typography>Loading...</Typography> : (
|
||||
<Box>
|
||||
{Object.keys(grouped).map(funding => (
|
||||
<Box key={funding} mb={4}>
|
||||
<Typography variant="h6" color="primary" mb={1}>{funding} Accounts</Typography>
|
||||
{['income', 'expense'].map(type => {
|
||||
const { monthTotals, actualTotal, budgetTotal, varianceTotal } = getTotalsForGroup(grouped[funding][type], entries);
|
||||
return (
|
||||
<Box key={type} mb={2}>
|
||||
<Typography variant="subtitle1" color={type === 'income' ? 'success.main' : 'error.main'}>{type.charAt(0).toUpperCase() + type.slice(1)}</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Category</TableCell>
|
||||
{MONTHS.map((m, i) => <TableCell key={m} align="right">{m}</TableCell>)}
|
||||
<TableCell align="right"><b>Actual</b></TableCell>
|
||||
<TableCell align="right"><b>Budget</b></TableCell>
|
||||
<TableCell align="right"><b>Variance</b></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{grouped[funding][type].map((cat: any) => {
|
||||
// Calculate summary values for the row
|
||||
let actualTotal = 0, budgetTotal = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const entry = entries.find((e: any) => e.category_id === cat.id && e.month === i + 1);
|
||||
if (typeof entry?.actual_amount === 'number' && entry.actual_amount !== null) {
|
||||
actualTotal += entry.actual_amount;
|
||||
budgetTotal += entry.actual_amount;
|
||||
} else if (typeof entry?.projected_amount === 'number' && entry.projected_amount !== null) {
|
||||
budgetTotal += entry.projected_amount;
|
||||
}
|
||||
}
|
||||
const variance = budgetTotal - actualTotal;
|
||||
return (
|
||||
<TableRow key={cat.id}>
|
||||
<TableCell>{cat.name}
|
||||
{user?.role === 'admin' && (
|
||||
<IconButton size="small" onClick={() => handleBulkClick(cat)}><AddIcon fontSize="small" /></IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
{MONTHS.map((m, i) => {
|
||||
const entry = entries.find(e => e.category_id === cat.id && e.month === i + 1);
|
||||
return (
|
||||
<TableCell
|
||||
key={m}
|
||||
align="right"
|
||||
sx={{ cursor: user?.role === 'admin' ? 'pointer' : 'default' }}
|
||||
onClick={() => handleCellClick(cat, i + 1)}
|
||||
>
|
||||
{entry ? (
|
||||
<span style={{ display: 'block', position: 'relative', minHeight: 32 }}>
|
||||
{typeof entry.actual_amount === 'number' && entry.actual_amount !== null ? (
|
||||
<span style={{ fontWeight: 'bold', color: '#000' }}>
|
||||
{entry.actual_amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof entry.projected_amount === 'number' && entry.projected_amount !== null ? (
|
||||
<span style={{ color: '#555', fontStyle: 'italic', marginLeft: 4 }}>
|
||||
{entry.actual_amount == null ? entry.projected_amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof entry.actual_amount === 'number' && typeof entry.projected_amount === 'number' && entry.actual_amount !== null && entry.projected_amount !== null ? (
|
||||
<span style={{ position: 'absolute', right: 4, bottom: 2, fontSize: '0.7em', color: (entry.projected_amount - entry.actual_amount) < 0 ? 'red' : '#000', background: '#fff', padding: '0 2px' }}>
|
||||
{entry.projected_amount - entry.actual_amount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
user?.role === 'admin' ? <span style={{ color: '#bbb' }}>+</span> : null
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align="right" style={{ fontWeight: 'bold' }}>{actualTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell align="right">{budgetTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell align="right" style={{ color: variance < 0 ? 'red' : '#000', fontWeight: 'bold' }}>{variance.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
<TableRow>
|
||||
<TableCell style={{ fontWeight: 'bold' }}>Totals</TableCell>
|
||||
{monthTotals.map((total, i) => (
|
||||
<TableCell key={i} align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{total ? total.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : ''}</TableCell>
|
||||
))}
|
||||
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{actualTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5' }}>{budgetTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell align="right" style={{ fontWeight: 'bold', background: '#f5f5f5', color: varianceTotal < 0 ? 'red' : '#000' }}>{varianceTotal.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
<Box mt={4}>
|
||||
<Typography variant="body2">
|
||||
<span>Legend: </span>
|
||||
<span style={{ fontStyle: 'italic', color: '#555' }}>Projected</span>
|
||||
<span> values are in italics, </span>
|
||||
<span style={{ fontWeight: 'bold', color: '#000' }}>Actuals</span>
|
||||
<span> are in bold.</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Dialog open={!!edit} onClose={() => setEdit(null)}>
|
||||
<DialogTitle>Edit Cash Flow Entry</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>{edit?.category?.name} - {MONTHS[(edit?.month || 1) - 1]} {year}</Typography>
|
||||
<TextField
|
||||
label="Projected Amount"
|
||||
name="projected_amount"
|
||||
type="number"
|
||||
value={form.projected_amount === 0 || form.projected_amount === '0' ? 0 : (form.projected_amount || '')}
|
||||
onChange={handleFormChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
label="Actual Amount"
|
||||
name="actual_amount"
|
||||
type="number"
|
||||
value={form.actual_amount === 0 || form.actual_amount === '0' ? 0 : (form.actual_amount || '')}
|
||||
onChange={handleFormChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{edit?.entry && <Button color="error" onClick={handleDelete}>Delete</Button>}
|
||||
<Button onClick={() => setEdit(null)}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={bulkDialog.open} onClose={() => setBulkDialog(b => ({ ...b, open: false }))}>
|
||||
<DialogTitle>Set Projected Value for All Months</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>Set projected value for <b>{bulkDialog.category?.name}</b> for all months in {year}?</Typography>
|
||||
<TextField
|
||||
label="Projected Amount"
|
||||
type="number"
|
||||
value={bulkDialog.value}
|
||||
onChange={handleBulkValueChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setBulkDialog(b => ({ ...b, open: false }))}>Cancel</Button>
|
||||
<Button onClick={handleBulkSubmit} variant="contained" color="primary">Set All</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={confirmDialog.open} onClose={() => setConfirmDialog({ open: false, onConfirm: null })}>
|
||||
<DialogTitle>Confirm Bulk Update</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Are you sure you want to overwrite all projected values for this category and year?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog({ open: false, onConfirm: null })}>Cancel</Button>
|
||||
<Button onClick={() => { confirmDialog.onConfirm && confirmDialog.onConfirm(); }} color="error" variant="contained">Yes, Overwrite</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CashFlows;
|
||||
520
backend/backend/frontend/src/Forecasting.tsx
Normal file
520
backend/backend/frontend/src/Forecasting.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, TextField, Tabs, Tab, Switch, FormControlLabel, useTheme, useMediaQuery, MenuItem
|
||||
} from '@mui/material';
|
||||
import { fetchAccounts, fetchForecast } from './api';
|
||||
// Add this at the top of the file or in a .d.ts file if needed
|
||||
// @ts-ignore
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const MONTHS = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
|
||||
// Define a default color palette for Plotly traces
|
||||
const plotlyColors = [
|
||||
'#1976d2', // blue
|
||||
'#9c27b0', // purple
|
||||
'#4caf50', // green
|
||||
'#ff9800', // orange
|
||||
'#e91e63', // pink
|
||||
'#f44336', // red
|
||||
'#00bcd4', // cyan
|
||||
'#607d8b', // blue grey
|
||||
'#795548', // brown
|
||||
'#8bc34a', // light green
|
||||
];
|
||||
|
||||
// Helper: get primary account for a bucket
|
||||
function getPrimaryAccount(accounts: any[], bucket: string) {
|
||||
// For now, hardcode by name; in future, use a 'primary' property
|
||||
if (bucket === 'Operating') {
|
||||
return accounts.find(a => a.name.toLowerCase().includes('checking 5345')) || accounts[0];
|
||||
} else if (bucket === 'Reserve') {
|
||||
return accounts.find(a => a.name.toLowerCase().includes('savings')) || accounts[0];
|
||||
}
|
||||
return accounts[0];
|
||||
}
|
||||
|
||||
// Custom Tooltip to ensure label and data are always in sync
|
||||
// Note: Recharts' TooltipProps typing is not compatible with destructuring, so use 'any' for props
|
||||
const CustomTooltip = (props: any) => {
|
||||
const { active, payload, label } = props;
|
||||
if (active && payload && payload.length) {
|
||||
// Find the data row for this label (rawDate)
|
||||
const row: any = payload[0].payload;
|
||||
return (
|
||||
<div style={{ background: 'white', border: '1px solid #ccc', padding: 12 }}>
|
||||
<div style={{ fontWeight: 600 }}>{row.date}</div>
|
||||
{payload.map((p: any, idx: number) => (
|
||||
<div key={idx} style={{ color: p.color }}>
|
||||
{p.name}: {typeof p.value === 'number' ? p.value.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) : p.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const Forecasting: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [forecast, setForecast] = useState<any[]>([]);
|
||||
const [tab, setTab] = useState<'Operating' | 'Reserve'>('Operating');
|
||||
const [months, setMonths] = useState(12);
|
||||
const [showLegend, setShowLegend] = useState(true);
|
||||
const [year, setYear] = useState<number>(new Date().getFullYear());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch accounts and forecast on tab/year/months change
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch accounts and forecast for the selected bucket and year
|
||||
fetchAccounts()
|
||||
.then(accs => {
|
||||
setAccounts(accs);
|
||||
// Filter accounts for the current tab (bucket)
|
||||
let filtered = accs.filter((a: any) => a.bucket?.name?.toLowerCase() === tab.toLowerCase());
|
||||
// Fetch forecast for the bucket
|
||||
if (filtered.length > 0) {
|
||||
fetchForecast({ bucket: tab, year, months })
|
||||
.then(forecastData => {
|
||||
setForecast(forecastData);
|
||||
setHasLoaded(true);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
// Debug logging
|
||||
console.debug('FORECAST: fetched forecast', forecastData);
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to fetch forecast data.');
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
console.error('Forecast fetch error:', err);
|
||||
});
|
||||
} else {
|
||||
setForecast([]);
|
||||
setHasLoaded(true);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to load accounts. Please try again.');
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
console.error('Accounts fetch error:', err);
|
||||
});
|
||||
}, [year, months, tab]);
|
||||
|
||||
// Filter accounts by tab (Operating/Reserve)
|
||||
const filteredAccounts = useMemo(() => {
|
||||
let filtered = accounts.filter((a: any) => {
|
||||
if (!a.bucket || !a.bucket.name) {
|
||||
console.warn('Account missing bucket or bucket.name:', a);
|
||||
}
|
||||
return a.bucket?.name?.toLowerCase() === tab.toLowerCase();
|
||||
});
|
||||
console.debug('FORECAST: filteredAccounts for tab', tab, filtered, 'from accounts', accounts);
|
||||
return filtered;
|
||||
}, [accounts, tab]);
|
||||
|
||||
// Sort accounts: primary first, then others (memoized for use in chart and elsewhere)
|
||||
const orderedAccounts = useMemo(() => {
|
||||
if (!filteredAccounts.length) {
|
||||
console.warn('FORECAST: No accounts found for tab', tab, '. Check if bucket property is present on accounts.');
|
||||
return [];
|
||||
}
|
||||
const primary = getPrimaryAccount(filteredAccounts, tab);
|
||||
const others = filteredAccounts.filter(a => a.id !== primary.id);
|
||||
return [primary, ...others];
|
||||
}, [filteredAccounts, tab]);
|
||||
|
||||
// Find the earliest validated date for the selected accounts
|
||||
const validatedDate = useMemo(() => {
|
||||
let minDate: Date | null = null;
|
||||
filteredAccounts.forEach((a: any) => {
|
||||
if (a.last_validated_at) {
|
||||
const d = new Date(a.last_validated_at);
|
||||
if (!minDate || d < minDate) minDate = d;
|
||||
}
|
||||
});
|
||||
return minDate;
|
||||
}, [filteredAccounts]);
|
||||
|
||||
// Build chart data: exactly 12 months for the selected year, Jan–Dec, using backend forecast only
|
||||
const chartData = useMemo(() => {
|
||||
if (!filteredAccounts.length || !forecast.length) {
|
||||
return [];
|
||||
}
|
||||
// Build a map of accountId -> forecast array (from backend)
|
||||
const forecastMap: Record<number, any[]> = {};
|
||||
forecast.forEach((f: any) => {
|
||||
forecastMap[f.account_id] = f.forecast;
|
||||
});
|
||||
// Generate 12 months for the selected year: Jan–Dec
|
||||
const months = Array.from({ length: 12 }, (_, i) => new Date(year, i, 1));
|
||||
let chartDataArr = months.map((dateObj) => {
|
||||
const rawDate = dateObj.toISOString().slice(0, 10); // YYYY-MM-01
|
||||
const label = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' });
|
||||
const row: any = { rawDate, date: label };
|
||||
filteredAccounts.forEach((a: any) => {
|
||||
// Use the backend forecast point for this month
|
||||
const fc = forecastMap[a.id]?.find(pt => pt.date === rawDate);
|
||||
row[`acc_${a.id}`] = fc ? fc.balance : null;
|
||||
});
|
||||
row.total = filteredAccounts.reduce((sum, a) => sum + (row[`acc_${a.id}`] || 0), 0);
|
||||
return row;
|
||||
});
|
||||
// Defensive: sort chartDataArr by rawDate
|
||||
chartDataArr.sort((a, b) => new Date(a.rawDate).getTime() - new Date(b.rawDate).getTime());
|
||||
// Do NOT filter chartDataArr; always return all 12 months
|
||||
// Debug: print the final chart data array
|
||||
console.debug('FORECAST: chartData (final, direct mapping)', chartDataArr);
|
||||
return chartDataArr;
|
||||
}, [filteredAccounts, forecast, year]);
|
||||
|
||||
// Remove any code that pads the start of the data array
|
||||
// Only pad the END with a dummy month (Jan of next year)
|
||||
const paddedChartData = (() => {
|
||||
if (!chartData.length) return [];
|
||||
const last = chartData[chartData.length - 1];
|
||||
// Add Jan of next year as a dummy row
|
||||
const janNext = { ...last, rawDate: `${year + 1}-01-01`, date: `Jan ${year + 1}` };
|
||||
Object.keys(janNext).forEach(k => { if (k.startsWith('acc_') || k === 'total') janNext[k] = null; });
|
||||
return [...chartData, janNext];
|
||||
})();
|
||||
// XAxis ticks: only Jan–Dec of selected year
|
||||
const xTicks = chartData.map(row => row.rawDate);
|
||||
|
||||
// Add a helper to convert rawDate (YYYY-MM-01) to a numeric value for X axis
|
||||
function dateToNum(rawDate: string): number {
|
||||
// Use YYYYMM as a number (e.g., 202501 for Jan 2025)
|
||||
const d = new Date(rawDate);
|
||||
return d.getFullYear() * 100 + (d.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Find months where only the PRIMARY account falls below $1000
|
||||
const alertMonths = useMemo(() => {
|
||||
if (!orderedAccounts.length) return [];
|
||||
const primaryId = orderedAccounts[0].id;
|
||||
return chartData.reduce<number[]>((acc, row: any, idx: number) => {
|
||||
// Use actual if present, else projected
|
||||
const bal = row[`acc_${primaryId}`] != null ? row[`acc_${primaryId}`] : null;
|
||||
if (bal < 1000) {
|
||||
acc.push(idx);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [chartData, orderedAccounts]);
|
||||
|
||||
// Find maturity markers for accounts with a maturity_date in the selected year
|
||||
const maturityMarkers = useMemo(() => {
|
||||
return orderedAccounts
|
||||
.filter(a => a.maturity_date)
|
||||
.map(a => {
|
||||
const maturity = new Date(a.maturity_date);
|
||||
if (maturity.getFullYear() !== year) return null;
|
||||
const monthIdx = maturity.getMonth(); // 0-based
|
||||
return {
|
||||
account: a,
|
||||
monthIdx,
|
||||
dateLabel: `${MONTHS[monthIdx]} ${year}`,
|
||||
maturityDate: maturity,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [orderedAccounts, year]);
|
||||
|
||||
// Colors for accounts
|
||||
const accountColors = useMemo(() => {
|
||||
const palette = [theme.palette.primary.main, theme.palette.secondary.main, '#4caf50', '#ff9800', '#e91e63', '#00bcd4', '#9c27b0'];
|
||||
const map: Record<number, string> = {};
|
||||
orderedAccounts.forEach((a, i) => {
|
||||
map[a.id] = palette[i % palette.length];
|
||||
});
|
||||
return map;
|
||||
}, [orderedAccounts, theme]);
|
||||
|
||||
// Debug: log chart keys
|
||||
useEffect(() => {
|
||||
if (filteredAccounts.length && chartData.length) {
|
||||
const keys = filteredAccounts.map(a => `acc_${a.id}`);
|
||||
console.debug('FORECAST: chart keys', keys);
|
||||
console.debug('FORECAST: chartData sample', chartData[0]);
|
||||
}
|
||||
}, [filteredAccounts, chartData]);
|
||||
|
||||
useEffect(() => {
|
||||
// Log filtered accounts and chartData
|
||||
console.debug('FORECAST: filteredAccounts', filteredAccounts);
|
||||
console.debug('FORECAST: chartData', chartData.slice(0, 5));
|
||||
}, [filteredAccounts, chartData]);
|
||||
|
||||
// Map chartData to include a numeric 'x' property for the X axis
|
||||
const chartDataWithX = chartData.map(row => ({ ...row, x: dateToNum(row.rawDate) }));
|
||||
|
||||
// Prepare ECharts series and options
|
||||
// Remove duplicate declaration of 'months' variable
|
||||
// Use 'monthLabels' for the X-axis labels
|
||||
const monthLabels = chartData.map(row => row.date); // e.g., ['Jan 2025', ...]
|
||||
const accountKeys = Object.keys(chartData[0] || {}).filter(k => k.startsWith('acc_'));
|
||||
const accountMap: Record<string, string> = {};
|
||||
filteredAccounts.forEach(a => {
|
||||
accountMap[`acc_${a.id}`] = a.name;
|
||||
});
|
||||
|
||||
// --- Alert markPoint for Checking 5345 ---
|
||||
let alertMarkPoint: any = undefined;
|
||||
if (tab === 'Operating' && alertMonths.length > 0 && orderedAccounts.length > 0) {
|
||||
const alertIdx = alertMonths[0];
|
||||
const alertMonthLabel = monthLabels[alertIdx];
|
||||
const alertBalance = chartData[alertIdx][`acc_${orderedAccounts[0].id}`];
|
||||
alertMarkPoint = {
|
||||
symbol: 'path://M0,40 L20,0 L40,40 Z', // triangle SVG path
|
||||
symbolSize: 40,
|
||||
itemStyle: { color: 'red' },
|
||||
label: {
|
||||
show: true,
|
||||
color: 'red',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
position: 'bottom', // place label below the symbol
|
||||
formatter: () => 'Alert',
|
||||
// Optionally, add padding to separate text from triangle
|
||||
padding: [4, 0, 0, 0],
|
||||
},
|
||||
data: [
|
||||
{
|
||||
name: 'Low Balance',
|
||||
coord: [alertMonthLabel, alertBalance],
|
||||
value: 'Alert',
|
||||
// Use symbol as triangle with a white exclamation mark overlay
|
||||
symbol: 'image://data:image/svg+xml;utf8,<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"><polygon points="20,0 40,40 0,40" fill="red"/><text x="20" y="30" text-anchor="middle" font-size="28" font-family="Arial" fill="white">!</text></svg>',
|
||||
symbolSize: 40,
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: () => `Alert: Balance below $1,000`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Determine current month for actual vs projected distinction
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth(); // 0-based
|
||||
|
||||
// Build account area series with actual vs projected styling
|
||||
const accountSeries = accountKeys.map((key, idx) => {
|
||||
const isPrimary = orderedAccounts.length && key === `acc_${orderedAccounts[0].id}`;
|
||||
const accountColor = accountColors[parseInt(key.replace('acc_', ''))] || plotlyColors[idx % plotlyColors.length];
|
||||
|
||||
// Create data with different itemStyle for actual vs projected
|
||||
const styledData = chartData.map((row, dataIdx) => {
|
||||
const rowDate = new Date(row.rawDate);
|
||||
const isProjected = rowDate.getFullYear() > 2025 ||
|
||||
(rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later
|
||||
|
||||
return {
|
||||
value: row[key],
|
||||
itemStyle: {
|
||||
color: accountColor,
|
||||
opacity: isProjected ? 0.4 : 1.0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: accountMap[key] || key,
|
||||
type: 'line',
|
||||
stack: 'accounts',
|
||||
areaStyle: {
|
||||
color: accountColor,
|
||||
opacity: 0.8
|
||||
},
|
||||
emphasis: { focus: 'series' },
|
||||
data: styledData,
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: accountColor,
|
||||
width: 2
|
||||
},
|
||||
...(isPrimary && alertMarkPoint ? { markPoint: alertMarkPoint } : {}),
|
||||
};
|
||||
});
|
||||
// Add total as a line with actual vs projected styling
|
||||
const totalStyledData = chartData.map((row, dataIdx) => {
|
||||
const rowDate = new Date(row.rawDate);
|
||||
const isProjected = rowDate.getFullYear() > 2025 ||
|
||||
(rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later
|
||||
|
||||
return {
|
||||
value: row.total,
|
||||
itemStyle: {
|
||||
color: '#f44336',
|
||||
opacity: isProjected ? 0.6 : 1.0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const totalSeries = {
|
||||
name: 'Total',
|
||||
type: 'line',
|
||||
data: totalStyledData,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: '#f44336'
|
||||
},
|
||||
symbol: 'circle',
|
||||
z: 10,
|
||||
smooth: true,
|
||||
};
|
||||
// Add a markLine to show the boundary between actual and projected data
|
||||
// Position the line between July and August (month 6 and 7, 0-based)
|
||||
const julyIndex = 6; // July is month 7, but 0-based index is 6
|
||||
const augustIndex = 7; // August is month 8, but 0-based index is 7
|
||||
|
||||
// Only add the markLine if we're viewing 2025 and have data for August
|
||||
const markLine = (year === 2025 && chartData.length > augustIndex) ? {
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#666',
|
||||
type: 'dashed',
|
||||
width: 1
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: 'Actual → Projected',
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#666'
|
||||
},
|
||||
data: [
|
||||
{ xAxis: julyIndex + 0.5 } // Position between July and August
|
||||
]
|
||||
} : null;
|
||||
|
||||
const series = [...accountSeries, totalSeries];
|
||||
|
||||
// Add markLine to the first series if it exists
|
||||
if (markLine && series.length > 0) {
|
||||
(series[0] as any).markLine = markLine;
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
valueFormatter: (v: number) => v?.toLocaleString('en-US', { style: 'currency', currency: 'USD' })
|
||||
},
|
||||
legend: {
|
||||
top: 0
|
||||
},
|
||||
grid: { left: 80, right: 32, bottom: 32, top: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: monthLabels,
|
||||
boundaryGap: false,
|
||||
axisLabel: { rotate: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { formatter: (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }) },
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" mb={2}>Forecasting</Typography>
|
||||
<Paper sx={{ p: isMobile ? 1 : 2, mb: 2, display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', gap: 2, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', gap: 2 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
textColor="primary"
|
||||
indicatorColor="primary"
|
||||
sx={{ minHeight: 36, height: 36, borderRadius: 2, background: theme.palette.background.paper, boxShadow: 1, fontSize: '0.95rem', mb: isMobile ? 1 : 0 }}
|
||||
>
|
||||
<Tab value="Operating" label="Operating" sx={{ minHeight: 36, height: 36, fontSize: '0.95rem' }} />
|
||||
<Tab value="Reserve" label="Reserve" sx={{ minHeight: 36, height: 36, fontSize: '0.95rem' }} />
|
||||
</Tabs>
|
||||
<TextField
|
||||
select
|
||||
label="Year"
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{[year - 1, year, year + 1].map(y => <MenuItem key={y} value={y}>{y}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Projection Months"
|
||||
type="number"
|
||||
value={months}
|
||||
onChange={e => setMonths(Number(e.target.value))}
|
||||
inputProps={{ min: 1, max: 36 }}
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showLegend} onChange={e => setShowLegend(e.target.checked)} />}
|
||||
label="Show Legend"
|
||||
sx={{ ml: isMobile ? 0 : 2 }}
|
||||
/>
|
||||
</Paper>
|
||||
<Paper sx={{ p: isMobile ? 1 : 2, mb: 2 }}>
|
||||
{loading && !hasLoaded ? (
|
||||
<Typography>Loading...</Typography>
|
||||
) : error ? (
|
||||
<Typography color="error" align="center" sx={{ mt: 4 }}>{error}</Typography>
|
||||
) : !accounts.length ? (
|
||||
<Typography color="error" align="center" sx={{ mt: 4 }}>
|
||||
No accounts loaded. Please check your backend or network connection.
|
||||
</Typography>
|
||||
) : !filteredAccounts.length ? (
|
||||
<Typography color="error" align="center" sx={{ mt: 4 }}>
|
||||
No accounts in this bucket.
|
||||
</Typography>
|
||||
) : !chartData.length ? (
|
||||
<Typography color="error" align="center" sx={{ mt: 4 }}>
|
||||
{`No forecast data exists for ${year}`}
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<ReactECharts option={option} style={{ height: 500, width: '100%' }} />
|
||||
{/* Show alert if primary account balance falls below $1000 in any month */}
|
||||
{tab === 'Operating' && alertMonths.length > 0 && (
|
||||
<Box mt={2} mb={2}>
|
||||
<Typography variant="body1" color="error" fontWeight={600}>
|
||||
Warning: Checking 5345 balance falls below $1,000 in {chartData[alertMonths[0]]?.date} (
|
||||
{chartData[alertMonths[0]] && chartData[alertMonths[0]][`acc_${orderedAccounts[0].id}`] != null
|
||||
? chartData[alertMonths[0]][`acc_${orderedAccounts[0].id}`].toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
|
||||
: 'N/A'}
|
||||
)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
<Box mt={2} mb={4}>
|
||||
<Paper sx={{ p: isMobile ? 1 : 2, background: theme.palette.grey[100] }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold" mb={1}>Assistant Suggestions (coming soon)</Typography>
|
||||
<Typography variant="body2" color="textSecondary">This area will provide AI-powered recommendations and alerts based on your forecasted balances and trends.</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Forecasting;
|
||||
60
backend/backend/frontend/src/Login.tsx
Normal file
60
backend/backend/frontend/src/Login.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Button, TextField, Typography, Paper, Alert } from '@mui/material';
|
||||
import { login, fetchUser } from './auth';
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from './App';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { setUser } = useContext(AuthContext);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(username, password);
|
||||
const user = await fetchUser();
|
||||
setUser(user);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError('Invalid username or password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||
<Paper elevation={3} sx={{ p: 4, minWidth: 320 }}>
|
||||
<Typography variant="h5" mb={2}>Login</Typography>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="contained" color="primary" fullWidth sx={{ mt: 2 }}>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
328
backend/backend/frontend/src/Transactions.tsx
Normal file
328
backend/backend/frontend/src/Transactions.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, IconButton, Switch, FormControlLabel } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { fetchTransactions, createTransaction, deleteTransaction, fetchAccounts, updateTransaction } from './api';
|
||||
import { AuthContext } from './App';
|
||||
|
||||
const transactionTypes = [
|
||||
{ value: 'deposit', label: 'Deposit' },
|
||||
{ value: 'withdrawal', label: 'Withdrawal' },
|
||||
{ value: 'transfer', label: 'Transfer' },
|
||||
];
|
||||
|
||||
const Transactions: React.FC = () => {
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
from_account_id: '',
|
||||
to_account_id: '',
|
||||
amount: '',
|
||||
planned_date: '',
|
||||
comments: '',
|
||||
reconciled: false,
|
||||
});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<any>(null);
|
||||
const [showReconciled, setShowReconciled] = useState(true);
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const load = async () => {
|
||||
setTransactions(await fetchTransactions());
|
||||
setAccounts(await fetchAccounts());
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Prepare payload for backend TransactionCreate
|
||||
const payload = {
|
||||
account_id: parseInt(form.from_account_id),
|
||||
related_account_id: parseInt(form.to_account_id),
|
||||
type: 'transfer',
|
||||
amount: parseFloat(form.amount),
|
||||
date: form.planned_date, // maps to backend 'date'
|
||||
description: form.comments, // maps to backend 'description'
|
||||
reconciled: form.reconciled,
|
||||
};
|
||||
await createTransaction(payload);
|
||||
setOpen(false);
|
||||
setForm({ from_account_id: '', to_account_id: '', amount: '', planned_date: '', comments: '', reconciled: false });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteTransaction(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleEditOpen = (tx: any) => {
|
||||
setEditForm({
|
||||
id: tx.id,
|
||||
from_account_id: tx.account_id?.toString() || '',
|
||||
to_account_id: tx.related_account_id?.toString() || '',
|
||||
amount: tx.amount?.toString() || '',
|
||||
planned_date: tx.date ? tx.date.substring(0, 10) : '',
|
||||
comments: tx.description || '',
|
||||
reconciled: !!tx.reconciled,
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
const handleEditClose = () => setEditOpen(false);
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEditForm((f: any) => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
|
||||
};
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editForm) return;
|
||||
const payload = {
|
||||
account_id: parseInt(editForm.from_account_id),
|
||||
related_account_id: parseInt(editForm.to_account_id),
|
||||
type: 'transfer',
|
||||
amount: parseFloat(editForm.amount),
|
||||
date: editForm.planned_date,
|
||||
description: editForm.comments,
|
||||
reconciled: editForm.reconciled,
|
||||
};
|
||||
await updateTransaction(editForm.id, payload);
|
||||
setEditOpen(false);
|
||||
setEditForm(null);
|
||||
load();
|
||||
};
|
||||
|
||||
// Sort accounts by bucket name, then account name
|
||||
const sortedAccounts = [...accounts].sort((a, b) => {
|
||||
const bucketA = (a.bucket?.name || '').toLowerCase();
|
||||
const bucketB = (b.bucket?.name || '').toLowerCase();
|
||||
if (bucketA < bucketB) return -1;
|
||||
if (bucketA > bucketB) return 1;
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
return 0;
|
||||
});
|
||||
// Sort transactions by planned date (ascending)
|
||||
const sortedTransactions = [...transactions].sort((a, b) => {
|
||||
const dateA = a.date || '';
|
||||
const dateB = b.date || '';
|
||||
return dateA.localeCompare(dateB);
|
||||
});
|
||||
// Filter transactions by reconciled toggle
|
||||
const filteredTransactions = showReconciled
|
||||
? sortedTransactions
|
||||
: sortedTransactions.filter(tx => !tx.reconciled);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h5">Transactions</Typography>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showReconciled} onChange={e => setShowReconciled(e.target.checked)} />}
|
||||
label="Show Reconciled"
|
||||
/>
|
||||
{user?.role === 'admin' && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpen}>Add Transaction</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Reconciled</TableCell>
|
||||
<TableCell>Planned Date</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>Transfer From</TableCell>
|
||||
<TableCell>Transfer To</TableCell>
|
||||
<TableCell>Comments</TableCell>
|
||||
{user?.role === 'admin' && <TableCell>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredTransactions.map(tx => (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell>{tx.reconciled ? '✔️' : ''}</TableCell>
|
||||
<TableCell>{tx.date ? tx.date.substring(0, 10) : ''}</TableCell>
|
||||
<TableCell>{tx.amount?.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}</TableCell>
|
||||
<TableCell>{(() => {
|
||||
const acc = accounts.find(a => a.id === tx.account_id);
|
||||
return acc ? `${acc.bucket?.name ? acc.bucket.name + '-' : ''}${acc.name}` : '';
|
||||
})()}</TableCell>
|
||||
<TableCell>{(() => {
|
||||
const acc = accounts.find(a => a.id === tx.related_account_id);
|
||||
return acc ? `${acc.bucket?.name ? acc.bucket.name + '-' : ''}${acc.name}` : '';
|
||||
})()}</TableCell>
|
||||
<TableCell>{tx.description}</TableCell>
|
||||
{user?.role === 'admin' && (
|
||||
<TableCell>
|
||||
<IconButton color="primary" onClick={() => handleEditOpen(tx)}><EditIcon /></IconButton>
|
||||
<IconButton color="error" onClick={() => handleDelete(tx.id)}><DeleteIcon /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Add Transaction</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reconciled"
|
||||
checked={form.reconciled}
|
||||
onChange={handleChange}
|
||||
id="reconciled-checkbox"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<label htmlFor="reconciled-checkbox">Reconciled</label>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Planned Date"
|
||||
name="planned_date"
|
||||
type="date"
|
||||
value={form.planned_date}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Dollar Amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
value={form.amount}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Transfer From"
|
||||
name="from_account_id"
|
||||
value={form.from_account_id}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
>
|
||||
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Transfer To"
|
||||
name="to_account_id"
|
||||
value={form.to_account_id}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
>
|
||||
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Comments"
|
||||
name="comments"
|
||||
value={form.comments}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained">Add</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={editOpen} onClose={handleEditClose}>
|
||||
<DialogTitle>Edit Transaction</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reconciled"
|
||||
checked={editForm?.reconciled || false}
|
||||
onChange={handleEditChange}
|
||||
id="edit-reconciled-checkbox"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<label htmlFor="edit-reconciled-checkbox">Reconciled</label>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Planned Date"
|
||||
name="planned_date"
|
||||
type="date"
|
||||
value={editForm?.planned_date || ''}
|
||||
onChange={handleEditChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Dollar Amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
value={editForm?.amount || ''}
|
||||
onChange={handleEditChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Transfer From"
|
||||
name="from_account_id"
|
||||
value={editForm?.from_account_id || ''}
|
||||
onChange={handleEditChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
>
|
||||
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="Transfer To"
|
||||
name="to_account_id"
|
||||
value={editForm?.to_account_id || ''}
|
||||
onChange={handleEditChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
>
|
||||
{sortedAccounts.map(a => <MenuItem key={a.id} value={a.id}>{(a.bucket?.name ? a.bucket.name + '-' : '') + a.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Comments"
|
||||
name="comments"
|
||||
value={editForm?.comments || ''}
|
||||
onChange={handleEditChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleEditClose}>Cancel</Button>
|
||||
<Button onClick={handleEditSubmit} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transactions;
|
||||
60
backend/backend/frontend/src/api.ts
Normal file
60
backend/backend/frontend/src/api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import axios from 'axios';
|
||||
import { getToken } from './auth';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- Accounts ---
|
||||
export const fetchAccounts = () => api.get('/accounts').then(res => res.data);
|
||||
export const fetchAccount = (id: number) => api.get(`/accounts/${id}`).then(res => res.data);
|
||||
export const fetchAccountHistory = (id: number) => api.get(`/accounts/${id}/history`).then(res => res.data);
|
||||
export const createAccount = (data: any) => api.post('/accounts', data).then(res => res.data);
|
||||
export const deleteAccount = (id: number) => api.delete(`/accounts/${id}`).then(res => res.data);
|
||||
export const updateAccount = (id: number, data: any) => api.patch(`/accounts/${id}`, data).then(res => res.data);
|
||||
|
||||
// --- Buckets ---
|
||||
export const fetchBuckets = () => api.get('/buckets').then(res => res.data);
|
||||
export const createBucket = (data: any) => api.post('/buckets', data).then(res => res.data);
|
||||
export const deleteBucket = (id: number) => api.delete(`/buckets/${id}`).then(res => res.data);
|
||||
|
||||
// --- Transactions ---
|
||||
export const fetchTransactions = () => api.get('/transactions').then(res => res.data);
|
||||
export const createTransaction = (data: any) => api.post('/transactions', data).then(res => res.data);
|
||||
export const updateTransaction = (id: number, data: any) => api.patch(`/transactions/${id}`, data).then(res => res.data);
|
||||
export const deleteTransaction = (id: number) => api.delete(`/transactions/${id}`).then(res => res.data);
|
||||
|
||||
// --- Cash Flows ---
|
||||
export const fetchCashFlows = () => api.get('/cashflows').then(res => res.data);
|
||||
export const createCashFlow = (data: any) => api.post('/cashflows', data).then(res => res.data);
|
||||
export const deleteCashFlow = (id: number) => api.delete(`/cashflows/${id}`).then(res => res.data);
|
||||
|
||||
// --- Cash Flow Categories ---
|
||||
export const fetchCashFlowCategories = () => api.get('/cashflow/categories').then(res => res.data);
|
||||
export const createCashFlowCategory = (data: any) => api.post('/cashflow/categories', data).then(res => res.data);
|
||||
export const updateCashFlowCategory = (id: number, data: any) => api.patch(`/cashflow/categories/${id}`, data).then(res => res.data);
|
||||
export const deleteCashFlowCategory = (id: number) => api.delete(`/cashflow/categories/${id}`).then(res => res.data);
|
||||
|
||||
// --- Cash Flow Entries ---
|
||||
export const fetchCashFlowEntries = (year?: number) => api.get('/cashflow/entries', { params: year ? { year } : {} }).then(res => res.data);
|
||||
export const createCashFlowEntry = (data: any) => api.post('/cashflow/entries', data).then(res => res.data);
|
||||
export const updateCashFlowEntry = (id: number, data: any) => api.patch(`/cashflow/entries/${id}`, data).then(res => res.data);
|
||||
export const deleteCashFlowEntry = (id: number) => api.delete(`/cashflow/entries/${id}`).then(res => res.data);
|
||||
|
||||
// --- Forecasting ---
|
||||
export const fetchForecast = (data: any) => api.post('/forecast', data).then(res => res.data);
|
||||
|
||||
// --- Account Types ---
|
||||
export const fetchAccountTypes = () => api.get('/account-types').then(res => res.data);
|
||||
export const createAccountType = (data: any) => api.post('/account-types', data).then(res => res.data);
|
||||
71
backend/backend/frontend/src/auth.ts
Normal file
71
backend/backend/frontend/src/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
export const login = async (username: string, password: string) => {
|
||||
console.log('LOGIN: Attempting login for', username);
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/token`, new URLSearchParams({
|
||||
username,
|
||||
password,
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
console.log('LOGIN: Response from backend', response);
|
||||
const { access_token } = response.data;
|
||||
if (!access_token) {
|
||||
console.error('LOGIN: No access token returned', response.data);
|
||||
throw new Error('No access token returned');
|
||||
}
|
||||
localStorage.setItem('token', access_token);
|
||||
console.log('LOGIN: Token stored in localStorage', access_token);
|
||||
return access_token;
|
||||
} catch (err: any) {
|
||||
console.error('LOGIN: Error during login', err);
|
||||
// If axios error, try to get backend error message
|
||||
if (err.response && err.response.data && err.response.data.detail) {
|
||||
console.error('LOGIN: Backend error detail', err.response.data.detail);
|
||||
throw new Error(err.response.data.detail);
|
||||
}
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = () => {
|
||||
console.log('LOGOUT: Clearing token and user from localStorage');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
export const getToken = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
console.log('GET_TOKEN: Retrieved token from localStorage', token);
|
||||
return token;
|
||||
};
|
||||
|
||||
export const fetchUser = async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
console.warn('FETCH_USER: No token found, returning null');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
console.log('FETCH_USER: Fetching user with token', token);
|
||||
const response = await axios.get(`${API_URL}/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
localStorage.setItem('user', JSON.stringify(response.data));
|
||||
console.log('FETCH_USER: User data from backend', response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('FETCH_USER: Error fetching user, logging out', err);
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUser = () => {
|
||||
const user = localStorage.getItem('user');
|
||||
console.log('GET_USER: Retrieved user from localStorage', user);
|
||||
return user ? JSON.parse(user) : null;
|
||||
};
|
||||
7
backend/backend/frontend/src/hoapro-logo.svg
Normal file
7
backend/backend/frontend/src/hoapro-logo.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
7
backend/backend/frontend/src/images/hoapro-logo.svg
Normal file
7
backend/backend/frontend/src/images/hoapro-logo.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
13
backend/backend/frontend/src/index.css
Normal file
13
backend/backend/frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
22
backend/backend/frontend/src/index.tsx
Normal file
22
backend/backend/frontend/src/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Register service worker for PWA
|
||||
serviceWorkerRegistration.register();
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
reportWebVitals();
|
||||
7
backend/backend/frontend/src/logo.svg
Normal file
7
backend/backend/frontend/src/logo.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
15
backend/backend/frontend/src/reportWebVitals.ts
Normal file
15
backend/backend/frontend/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
80
backend/backend/frontend/src/service-worker.ts
Normal file
80
backend/backend/frontend/src/service-worker.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/// <reference lib="webworker" />
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
// This service worker can be customized!
|
||||
// See https://developers.google.com/web/tools/workbox/modules
|
||||
// for the list of available Workbox modules, or add any other
|
||||
// code you'd like.
|
||||
// You can also remove this file if you'd prefer not to use a
|
||||
// service worker, and the Workbox build step will be skipped.
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
clientsClaim();
|
||||
|
||||
// Precache all of the assets generated by your build process.
|
||||
// Their URLs are injected into the manifest variable below.
|
||||
// This variable must be present somewhere in your service worker file,
|
||||
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// Set up App Shell-style routing, so that all navigation requests
|
||||
// are fulfilled with your index.html shell. Learn more at
|
||||
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||
registerRoute(
|
||||
// Return false to exempt requests from being fulfilled by index.html.
|
||||
({ request, url }: { request: Request; url: URL }) => {
|
||||
// If this isn't a navigation, skip.
|
||||
if (request.mode !== 'navigate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this is a URL that starts with /_, skip.
|
||||
if (url.pathname.startsWith('/_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this looks like a URL for a resource, because it contains
|
||||
// a file extension, skip.
|
||||
if (url.pathname.match(fileExtensionRegexp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return true to signal that we want to use the handler.
|
||||
return true;
|
||||
},
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||
);
|
||||
|
||||
// An example runtime caching route for requests that aren't handled by the
|
||||
// precache, in this case same-origin .png requests like those from in public/
|
||||
registerRoute(
|
||||
// Add in any other file extensions or routing criteria as needed.
|
||||
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
// Ensure that once this runtime cache reaches a maximum size the
|
||||
// least-recently used images are removed.
|
||||
new ExpirationPlugin({ maxEntries: 50 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// This allows the web app to trigger skipWaiting via
|
||||
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// Any other custom service worker logic can go here.
|
||||
142
backend/backend/frontend/src/serviceWorkerRegistration.ts
Normal file
142
backend/backend/frontend/src/serviceWorkerRegistration.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://cra.link/PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL ?? '', window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://cra.link/PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' },
|
||||
})
|
||||
.then((response) => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log('No internet connection found. App is running in offline mode.');
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
registration.unregister();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
5
backend/backend/frontend/src/setupTests.ts
Normal file
5
backend/backend/frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
24
backend/backend/frontend/src/theme.ts
Normal file
24
backend/backend/frontend/src/theme.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const getTheme = (mode: 'light' | 'dark') =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: mode === 'light' ? '#1976d2' : '#90caf9',
|
||||
},
|
||||
background: {
|
||||
default: mode === 'light' ? '#f5f5f5' : '#121212',
|
||||
paper: mode === 'light' ? '#fff' : '#1e1e1e',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
colorPrimary: {
|
||||
backgroundColor: mode === 'light' ? '#1976d2' : '#23272f',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
15
backend/backend/frontend/tsconfig.json
Normal file
15
backend/backend/frontend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
BIN
backend/backend/hoa_app.db
Normal file
BIN
backend/backend/hoa_app.db
Normal file
Binary file not shown.
1
backend/backend/hoa_app/__init__.py
Normal file
1
backend/backend/hoa_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
backend/backend/hoa_app/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/backend/hoa_app/__pycache__/auth.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/backend/hoa_app/__pycache__/database.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/database.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/backend/hoa_app/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/backend/hoa_app/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/backend/hoa_app/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/backend/hoa_app/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
2
backend/backend/hoa_app/api.py
Normal file
2
backend/backend/hoa_app/api.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# If you get a network error or 500 from the backend, display a user-friendly message to the user, e.g.:
|
||||
# setError("Backend service unavailable. Please try again later.")
|
||||
104
backend/backend/hoa_app/auth.py
Normal file
104
backend/backend/hoa_app/auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import User, RoleEnum
|
||||
from .database import SessionLocal
|
||||
from .logging_config import setup_logging, DEBUG_LOGGING
|
||||
import logging
|
||||
|
||||
# Secret key for JWT
|
||||
SECRET_KEY = "your-secret-key" # Replace with a secure key in production
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
|
||||
|
||||
setup_logging()
|
||||
auth_logger = logging.getLogger("hoa_app.auth")
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
try:
|
||||
result = pwd_context.verify(plain_password, hashed_password)
|
||||
if DEBUG_LOGGING:
|
||||
auth_logger.debug(f"Password verification result: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
auth_logger.error(f"Password verification error: {e}")
|
||||
return False
|
||||
|
||||
def get_password_hash(password):
|
||||
hash = pwd_context.hash(password)
|
||||
if DEBUG_LOGGING:
|
||||
auth_logger.debug(f"Generated password hash: {hash}")
|
||||
return hash
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
if DEBUG_LOGGING:
|
||||
auth_logger.debug(f"Created JWT for {data.get('sub')}")
|
||||
return encoded_jwt
|
||||
|
||||
def get_user_by_username(db: Session, username: str):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if DEBUG_LOGGING:
|
||||
auth_logger.debug(f"User lookup for '{username}': {'found' if user else 'not found'}")
|
||||
return user
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
user = get_user_by_username(db, username)
|
||||
if not user:
|
||||
auth_logger.warning(f"Authentication failed: user '{username}' not found.")
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
auth_logger.warning(f"Authentication failed: invalid password for user '{username}'.")
|
||||
return None
|
||||
auth_logger.info(f"Authentication successful for user '{username}'.")
|
||||
return user
|
||||
|
||||
def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not isinstance(username, str):
|
||||
auth_logger.warning("JWT decode failed: no username in token or not a string.")
|
||||
raise credentials_exception
|
||||
except JWTError as e:
|
||||
auth_logger.warning(f"JWT decode error: {e}")
|
||||
raise credentials_exception
|
||||
user = get_user_by_username(db, username)
|
||||
if user is None:
|
||||
auth_logger.warning(f"JWT user not found: {username}")
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role.name != RoleEnum.admin:
|
||||
auth_logger.warning(f"Admin privileges required. User: {current_user.username}")
|
||||
raise HTTPException(status_code=403, detail="Admin privileges required")
|
||||
return current_user
|
||||
23
backend/backend/hoa_app/database.py
Normal file
23
backend/backend/hoa_app/database.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .models import Base
|
||||
from .logging_config import setup_logging, DEBUG_LOGGING
|
||||
import logging
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./hoa_app.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
setup_logging()
|
||||
db_logger = logging.getLogger("hoa_app.database")
|
||||
|
||||
def init_db():
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db_logger.info("Database tables created/verified.")
|
||||
except Exception as e:
|
||||
db_logger.error(f"Database initialization error: {e}")
|
||||
raise
|
||||
0
backend/backend/hoa_app/hoa.db
Normal file
0
backend/backend/hoa_app/hoa.db
Normal file
29
backend/backend/hoa_app/logging_config.py
Normal file
29
backend/backend/hoa_app/logging_config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
|
||||
# Debug toggle: set to True for verbose debug logging
|
||||
DEBUG_LOGGING = os.environ.get('HOA_APP_DEBUG', '1') == '1'
|
||||
|
||||
LOG_LEVEL = logging.DEBUG if DEBUG_LOGGING else logging.INFO
|
||||
LOG_FILE = os.environ.get('HOA_APP_LOGFILE', 'hoa_app.log')
|
||||
|
||||
formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s')
|
||||
|
||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=2*1024*1024, backupCount=3)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(LOG_LEVEL)
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(LOG_LEVEL)
|
||||
|
||||
def setup_logging():
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(LOG_LEVEL)
|
||||
# Avoid duplicate handlers if re-imported
|
||||
if not any(isinstance(h, RotatingFileHandler) for h in root_logger.handlers):
|
||||
root_logger.addHandler(file_handler)
|
||||
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) for h in root_logger.handlers):
|
||||
root_logger.addHandler(console_handler)
|
||||
logging.debug(f"Logging initialized. Level: {LOG_LEVEL}, File: {LOG_FILE}, Debug: {DEBUG_LOGGING}")
|
||||
856
backend/backend/hoa_app/main.py
Normal file
856
backend/backend/hoa_app/main.py
Normal file
@@ -0,0 +1,856 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from . import models, schemas, auth, database
|
||||
from .models import User, Role, RoleEnum
|
||||
from .auth import get_db, get_password_hash, authenticate_user, create_access_token, get_current_active_user, get_current_admin_user
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .schemas import BucketCreate, BucketRead, AccountCreate, AccountRead, TransactionCreate, TransactionRead, CashFlowCreate, CashFlowRead, ForecastRequest, ForecastResponse, ForecastPoint, ReportRequest, ReportResponse, ReportEntry, CashFlowCategoryCreate, CashFlowCategoryRead, CashFlowEntryCreate, CashFlowEntryRead
|
||||
from datetime import datetime, timedelta, date as date_cls
|
||||
from sqlalchemy import and_, func
|
||||
from .logging_config import setup_logging, DEBUG_LOGGING
|
||||
import logging
|
||||
import os
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.requests import Request
|
||||
from collections import defaultdict
|
||||
from bisect import bisect_left
|
||||
import calendar
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://192.168.2.52:3000",
|
||||
"http://starship2:3000"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_default_admin(db: Session):
|
||||
logger.info("Ensuring default admin and user roles exist.")
|
||||
admin_role = db.query(Role).filter(Role.name == RoleEnum.admin).first()
|
||||
if not admin_role:
|
||||
admin_role = Role(name=RoleEnum.admin)
|
||||
db.add(admin_role)
|
||||
db.commit()
|
||||
db.refresh(admin_role)
|
||||
logger.info("Created admin role.")
|
||||
user_role = db.query(Role).filter(Role.name == RoleEnum.user).first()
|
||||
if not user_role:
|
||||
user_role = Role(name=RoleEnum.user)
|
||||
db.add(user_role)
|
||||
db.commit()
|
||||
logger.info("Created user role.")
|
||||
admin_user = db.query(User).filter(User.username == "admin").first()
|
||||
admin_password = os.environ.get("ADMIN_PASSWORD", "admin")
|
||||
if not admin_user:
|
||||
admin_user = User(
|
||||
username="admin",
|
||||
password_hash=get_password_hash(admin_password),
|
||||
role_id=admin_role.id
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
logger.info(f"Created default admin user with username 'admin' and password from {'ADMIN_PASSWORD env' if os.environ.get('ADMIN_PASSWORD') else 'default' }.")
|
||||
else:
|
||||
# Check if the password hash matches the current ADMIN_PASSWORD
|
||||
from .auth import verify_password
|
||||
if not verify_password(admin_password, admin_user.password_hash):
|
||||
logger.warning("Admin password hash does not match ADMIN_PASSWORD. To reset, delete the admin user or set ADMIN_PASSWORD and remove the user.")
|
||||
logger.debug("Admin user already exists.")
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
logger.info("Starting up application and initializing database.")
|
||||
database.init_db()
|
||||
db = next(get_db())
|
||||
create_default_admin(db)
|
||||
# Health check: verify bcrypt and passlib
|
||||
try:
|
||||
import bcrypt
|
||||
import passlib
|
||||
logger.info(f"bcrypt version: {getattr(bcrypt, '__version__', 'unknown')}, passlib version: {getattr(passlib, '__version__', 'unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Dependency check failed: {e}")
|
||||
db.close()
|
||||
logger.info("Startup complete.")
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint for service and dependency status."""
|
||||
try:
|
||||
import bcrypt
|
||||
import passlib
|
||||
bcrypt_version = getattr(bcrypt, '__version__', 'unknown')
|
||||
passlib_version = getattr(passlib, '__version__', 'unknown')
|
||||
return {"status": "ok", "bcrypt_version": bcrypt_version, "passlib_version": passlib_version}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": "Backend service unavailable. Please check the logs for more information."},
|
||||
)
|
||||
|
||||
@app.post("/register", response_model=schemas.UserRead)
|
||||
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Register endpoint called for username: {user.username}")
|
||||
user_role = db.query(Role).filter(Role.name == RoleEnum.user).first()
|
||||
if not user_role:
|
||||
logger.error("User role not found during registration.")
|
||||
raise HTTPException(status_code=500, detail="User role not found.")
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
password_hash=get_password_hash(user.password),
|
||||
role_id=user_role.id
|
||||
)
|
||||
db.add(db_user)
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
logger.info(f"User registered: {user.username}")
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
logger.warning(f"Registration failed: Username already registered: {user.username}")
|
||||
raise HTTPException(status_code=400, detail="Username already registered.")
|
||||
return schemas.UserRead(id=db_user.id, username=db_user.username, role=db_user.role.name)
|
||||
|
||||
@app.post("/token", response_model=schemas.Token)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
logger.info(f"Login attempt for username: {form_data.username}")
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
logger.warning(f"Login failed for username: {form_data.username}")
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
logger.info(f"Login successful for username: {form_data.username}")
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@app.get("/me", response_model=schemas.UserRead)
|
||||
def read_users_me(current_user: User = Depends(get_current_active_user)):
|
||||
logger.debug(f"/me endpoint called for user: {current_user.username}")
|
||||
return schemas.UserRead(id=current_user.id, username=current_user.username, role=current_user.role.name)
|
||||
|
||||
@app.get("/admin-only")
|
||||
def admin_only(current_user: User = Depends(get_current_admin_user)):
|
||||
logger.debug(f"/admin-only endpoint accessed by: {current_user.username}")
|
||||
return {"message": f"Hello, {current_user.username}. You are an admin."}
|
||||
|
||||
# --- Bucket Endpoints ---
|
||||
@app.post("/buckets", response_model=BucketRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_bucket(bucket: BucketCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create bucket called: {bucket.name}")
|
||||
if not bucket.name or len(bucket.name) < 2:
|
||||
logger.warning("Bucket name too short.")
|
||||
raise HTTPException(status_code=422, detail="Bucket name must be at least 2 characters.")
|
||||
if db.query(models.Bucket).filter(models.Bucket.name == bucket.name).first():
|
||||
logger.warning(f"Bucket name already exists: {bucket.name}")
|
||||
raise HTTPException(status_code=400, detail="Bucket name already exists.")
|
||||
db_bucket = models.Bucket(name=bucket.name, description=bucket.description)
|
||||
db.add(db_bucket)
|
||||
db.commit()
|
||||
db.refresh(db_bucket)
|
||||
logger.info(f"Bucket created: {bucket.name}")
|
||||
return db_bucket
|
||||
|
||||
@app.get("/buckets", response_model=list[BucketRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_buckets(db: Session = Depends(get_db)):
|
||||
logger.debug("List buckets called.")
|
||||
return db.query(models.Bucket).all()
|
||||
|
||||
@app.get("/buckets/{bucket_id}", response_model=BucketRead, dependencies=[Depends(get_current_active_user)])
|
||||
def get_bucket(bucket_id: int, db: Session = Depends(get_db)):
|
||||
logger.debug(f"Get bucket called: {bucket_id}")
|
||||
bucket = db.query(models.Bucket).filter(models.Bucket.id == bucket_id).first()
|
||||
if not bucket:
|
||||
logger.warning(f"Bucket not found: {bucket_id}")
|
||||
raise HTTPException(status_code=404, detail="Bucket not found")
|
||||
return bucket
|
||||
|
||||
@app.delete("/buckets/{bucket_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_bucket(bucket_id: int, db: Session = Depends(get_db)):
|
||||
logger.info(f"Delete bucket called: {bucket_id}")
|
||||
bucket = db.query(models.Bucket).filter(models.Bucket.id == bucket_id).first()
|
||||
if not bucket:
|
||||
logger.warning(f"Bucket not found for delete: {bucket_id}")
|
||||
raise HTTPException(status_code=404, detail="Bucket not found")
|
||||
db.delete(bucket)
|
||||
db.commit()
|
||||
logger.info(f"Bucket deleted: {bucket_id}")
|
||||
return {"detail": "Bucket deleted"}
|
||||
|
||||
# --- Account Endpoints ---
|
||||
@app.post("/account-types", response_model=schemas.AccountTypeRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_account_type(account_type: schemas.AccountTypeCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create account type called: {account_type.name}")
|
||||
if db.query(models.AccountType).filter(models.AccountType.name == account_type.name).first():
|
||||
logger.warning(f"Account type already exists: {account_type.name}")
|
||||
raise HTTPException(status_code=400, detail="Account type already exists.")
|
||||
db_account_type = models.AccountType(name=account_type.name)
|
||||
db.add(db_account_type)
|
||||
db.commit()
|
||||
db.refresh(db_account_type)
|
||||
logger.info(f"Account type created: {account_type.name}")
|
||||
return db_account_type
|
||||
|
||||
@app.get("/account-types", response_model=list[schemas.AccountTypeRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_account_types(db: Session = Depends(get_db)):
|
||||
logger.debug("List account types called.")
|
||||
return db.query(models.AccountType).all()
|
||||
|
||||
@app.post("/accounts", response_model=schemas.AccountRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_account(account: schemas.AccountCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create account called: {account.name}")
|
||||
maturity_date = account.maturity_date
|
||||
if isinstance(maturity_date, str) and maturity_date:
|
||||
try:
|
||||
maturity_date = datetime.fromisoformat(maturity_date)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid maturity_date format: {maturity_date}")
|
||||
raise HTTPException(status_code=400, detail="Invalid maturity_date format. Use YYYY-MM-DD.")
|
||||
db_account = models.Account(
|
||||
name=account.name,
|
||||
institution_name=account.institution_name,
|
||||
interest_rate=account.interest_rate,
|
||||
maturity_date=maturity_date,
|
||||
balance=account.balance,
|
||||
bucket_id=account.bucket_id,
|
||||
account_type_id=account.account_type_id
|
||||
)
|
||||
db.add(db_account)
|
||||
db.commit()
|
||||
db.refresh(db_account)
|
||||
# Create initial balance history
|
||||
new_history = models.AccountBalanceHistory(
|
||||
account_id=db_account.id,
|
||||
balance=db_account.balance,
|
||||
date=datetime.utcnow()
|
||||
)
|
||||
db.add(new_history)
|
||||
db.commit()
|
||||
logger.info(f"Account created: {account.name}")
|
||||
return db_account
|
||||
|
||||
@app.get("/accounts", response_model=list[AccountRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_accounts(db: Session = Depends(get_db)):
|
||||
logger.debug("List accounts called.")
|
||||
return db.query(models.Account).all()
|
||||
|
||||
@app.get("/accounts/{account_id}", response_model=AccountRead, dependencies=[Depends(get_current_active_user)])
|
||||
def get_account(account_id: int, db: Session = Depends(get_db)):
|
||||
logger.debug(f"Get account called: {account_id}")
|
||||
account = db.query(models.Account).filter(models.Account.id == account_id).first()
|
||||
if not account:
|
||||
logger.warning(f"Account not found: {account_id}")
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return account
|
||||
|
||||
@app.delete("/accounts/{account_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_account(account_id: int, db: Session = Depends(get_db)):
|
||||
logger.info(f"Delete account called: {account_id}")
|
||||
account = db.query(models.Account).filter(models.Account.id == account_id).first()
|
||||
if not account:
|
||||
logger.warning(f"Account not found for delete: {account_id}")
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
logger.info(f"Account deleted: {account_id}")
|
||||
return {"detail": "Account deleted"}
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
institution_name: Optional[str] = None
|
||||
interest_rate: Optional[float] = None
|
||||
maturity_date: Optional[str] = None
|
||||
balance: Optional[float] = None
|
||||
bucket_id: Optional[int] = None
|
||||
account_type_id: Optional[int] = None
|
||||
# last_validated_at is not user-editable except via balance update
|
||||
|
||||
@app.patch("/accounts/{account_id}", response_model=schemas.AccountRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def update_account(account_id: int, update: AccountUpdate = Body(...), db: Session = Depends(get_db)):
|
||||
logger.info(f"Update account called: {account_id}")
|
||||
account = db.query(models.Account).filter(models.Account.id == account_id).first()
|
||||
if not account:
|
||||
logger.warning(f"Account not found for update: {account_id}")
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
data = update.dict(exclude_unset=True)
|
||||
if 'maturity_date' in data and data['maturity_date']:
|
||||
try:
|
||||
data['maturity_date'] = datetime.fromisoformat(data['maturity_date'])
|
||||
except Exception:
|
||||
logger.error(f"Invalid maturity_date format: {data['maturity_date']}")
|
||||
raise HTTPException(status_code=400, detail="Invalid maturity_date format. Use YYYY-MM-DD.")
|
||||
for key, value in data.items():
|
||||
setattr(account, key, value)
|
||||
# If balance is updated, also update last_validated_at and record history
|
||||
if 'balance' in data:
|
||||
account.last_validated_at = datetime.utcnow()
|
||||
# Record or update today's balance history
|
||||
today = datetime.utcnow().date()
|
||||
history = db.query(models.AccountBalanceHistory).filter(
|
||||
models.AccountBalanceHistory.account_id == account.id,
|
||||
models.AccountBalanceHistory.date >= datetime.combine(today, datetime.min.time()),
|
||||
models.AccountBalanceHistory.date <= datetime.combine(today, datetime.max.time())
|
||||
).first()
|
||||
if history:
|
||||
history.balance = account.balance
|
||||
history.date = datetime.utcnow()
|
||||
logger.debug(f"Updated balance history for account {account.id} on {today}: {account.balance}")
|
||||
else:
|
||||
new_history = models.AccountBalanceHistory(
|
||||
account_id=account.id,
|
||||
balance=account.balance,
|
||||
date=datetime.utcnow()
|
||||
)
|
||||
db.add(new_history)
|
||||
logger.debug(f"Created balance history for account {account.id} on {today}: {account.balance}")
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
logger.info(f"Account updated: {account_id}")
|
||||
return account
|
||||
|
||||
@app.get("/accounts/{account_id}/history", response_model=List[schemas.AccountBalanceHistoryRead], dependencies=[Depends(get_current_active_user)])
|
||||
def get_account_balance_history(account_id: int, db: Session = Depends(get_db)):
|
||||
logger.info(f"Get balance history for account: {account_id}")
|
||||
history = db.query(models.AccountBalanceHistory).filter(models.AccountBalanceHistory.account_id == account_id).order_by(models.AccountBalanceHistory.date.asc()).all()
|
||||
return history
|
||||
|
||||
# --- Transaction Endpoints ---
|
||||
@app.post("/transactions", response_model=TransactionRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_transaction(transaction: TransactionCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create transaction called for account: {transaction.account_id}")
|
||||
# Parse date string to datetime if necessary
|
||||
txn_date = transaction.date
|
||||
if isinstance(txn_date, str) and txn_date:
|
||||
try:
|
||||
txn_date = datetime.fromisoformat(txn_date)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid transaction date format: {txn_date}")
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD or ISO format.")
|
||||
db_transaction = models.Transaction(
|
||||
account_id=transaction.account_id,
|
||||
type=transaction.type,
|
||||
amount=transaction.amount,
|
||||
date=txn_date or None,
|
||||
description=transaction.description,
|
||||
related_account_id=transaction.related_account_id,
|
||||
reconciled=transaction.reconciled if transaction.reconciled is not None else False
|
||||
)
|
||||
db.add(db_transaction)
|
||||
db.commit()
|
||||
db.refresh(db_transaction)
|
||||
logger.info(f"Transaction created for account: {transaction.account_id}")
|
||||
# Ensure date is returned as ISO string for response
|
||||
result = db_transaction
|
||||
if hasattr(result, 'date') and isinstance(result.date, datetime):
|
||||
result.date = result.date.isoformat()
|
||||
return result
|
||||
|
||||
@app.get("/transactions", response_model=list[TransactionRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_transactions(db: Session = Depends(get_db)):
|
||||
logger.debug("List transactions called.")
|
||||
transactions = db.query(models.Transaction).all()
|
||||
# Ensure all dates are ISO strings
|
||||
for t in transactions:
|
||||
if hasattr(t, 'date') and isinstance(t.date, datetime):
|
||||
t.date = t.date.isoformat()
|
||||
return transactions
|
||||
|
||||
@app.get("/transactions/{transaction_id}", response_model=TransactionRead, dependencies=[Depends(get_current_active_user)])
|
||||
def get_transaction(transaction_id: int, db: Session = Depends(get_db)):
|
||||
logger.debug(f"Get transaction called: {transaction_id}")
|
||||
transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
|
||||
if not transaction:
|
||||
logger.warning(f"Transaction not found: {transaction_id}")
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
# Ensure date is ISO string
|
||||
if hasattr(transaction, 'date') and isinstance(transaction.date, datetime):
|
||||
transaction.date = transaction.date.isoformat()
|
||||
return transaction
|
||||
|
||||
@app.delete("/transactions/{transaction_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_transaction(transaction_id: int, db: Session = Depends(get_db)):
|
||||
logger.info(f"Delete transaction called: {transaction_id}")
|
||||
transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
|
||||
if not transaction:
|
||||
logger.warning(f"Transaction not found for delete: {transaction_id}")
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
db.delete(transaction)
|
||||
db.commit()
|
||||
logger.info(f"Transaction deleted: {transaction_id}")
|
||||
return {"detail": "Transaction deleted"}
|
||||
|
||||
@app.patch("/transactions/{transaction_id}", response_model=TransactionRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def update_transaction(transaction_id: int, transaction: TransactionCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Update transaction called for id: {transaction_id}")
|
||||
db_transaction = db.query(models.Transaction).filter(models.Transaction.id == transaction_id).first()
|
||||
if not db_transaction:
|
||||
logger.warning(f"Transaction not found for update: {transaction_id}")
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
# Parse date string to datetime if necessary
|
||||
txn_date = transaction.date
|
||||
if isinstance(txn_date, str) and txn_date:
|
||||
try:
|
||||
txn_date = datetime.fromisoformat(txn_date)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid transaction date format: {txn_date}")
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD or ISO format.")
|
||||
db_transaction.account_id = transaction.account_id
|
||||
db_transaction.related_account_id = transaction.related_account_id
|
||||
db_transaction.type = transaction.type
|
||||
db_transaction.amount = transaction.amount
|
||||
db_transaction.date = txn_date or None
|
||||
db_transaction.description = transaction.description
|
||||
db_transaction.reconciled = transaction.reconciled if transaction.reconciled is not None else False
|
||||
db.commit()
|
||||
db.refresh(db_transaction)
|
||||
logger.info(f"Transaction updated for id: {transaction_id}")
|
||||
# Ensure date is returned as ISO string for response
|
||||
result = db_transaction
|
||||
if hasattr(result, 'date') and isinstance(result.date, datetime):
|
||||
result.date = result.date.isoformat()
|
||||
return result
|
||||
|
||||
# --- Cash Flow Endpoints ---
|
||||
@app.post("/cashflows", response_model=CashFlowRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_cashflow(cashflow: CashFlowCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create cashflow called for account: {cashflow.account_id}")
|
||||
db_cashflow = models.CashFlow(
|
||||
account_id=cashflow.account_id,
|
||||
type=cashflow.type,
|
||||
estimate_actual=cashflow.estimate_actual,
|
||||
amount=cashflow.amount,
|
||||
date=cashflow.date,
|
||||
description=cashflow.description
|
||||
)
|
||||
db.add(db_cashflow)
|
||||
db.commit()
|
||||
db.refresh(db_cashflow)
|
||||
logger.info(f"Cashflow created for account: {cashflow.account_id}")
|
||||
return db_cashflow
|
||||
|
||||
@app.get("/cashflows", response_model=list[CashFlowRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_cashflows(db: Session = Depends(get_db)):
|
||||
logger.debug("List cashflows called.")
|
||||
try:
|
||||
return db.query(models.CashFlow).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching cash flows: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Could not fetch cash flows. Backend error.")
|
||||
|
||||
@app.get("/cashflows/{cashflow_id}", response_model=CashFlowRead, dependencies=[Depends(get_current_active_user)])
|
||||
def get_cashflow(cashflow_id: int, db: Session = Depends(get_db)):
|
||||
logger.debug(f"Get cashflow called: {cashflow_id}")
|
||||
cashflow = db.query(models.CashFlow).filter(models.CashFlow.id == cashflow_id).first()
|
||||
if not cashflow:
|
||||
logger.warning(f"Cashflow not found: {cashflow_id}")
|
||||
raise HTTPException(status_code=404, detail="Cashflow not found")
|
||||
return cashflow
|
||||
|
||||
@app.delete("/cashflows/{cashflow_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_cashflow(cashflow_id: int, db: Session = Depends(get_db)):
|
||||
logger.info(f"Delete cashflow called: {cashflow_id}")
|
||||
cashflow = db.query(models.CashFlow).filter(models.CashFlow.id == cashflow_id).first()
|
||||
if not cashflow:
|
||||
logger.warning(f"Cashflow not found for delete: {cashflow_id}")
|
||||
raise HTTPException(status_code=404, detail="Cashflow not found")
|
||||
db.delete(cashflow)
|
||||
db.commit()
|
||||
logger.info(f"Cashflow deleted: {cashflow_id}")
|
||||
return {"detail": "Cashflow deleted"}
|
||||
|
||||
# --- Cash Flow Category Endpoints ---
|
||||
@app.post("/cashflow/categories", response_model=CashFlowCategoryRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_cashflow_category(category: CashFlowCategoryCreate, db: Session = Depends(get_db)):
|
||||
logger.info(f"Create cash flow category called: {category.name}")
|
||||
db_cat = models.CashFlowCategory(
|
||||
name=category.name,
|
||||
category_type=category.category_type,
|
||||
funding_type=category.funding_type
|
||||
)
|
||||
db.add(db_cat)
|
||||
db.commit()
|
||||
db.refresh(db_cat)
|
||||
return db_cat
|
||||
|
||||
@app.get("/cashflow/categories", response_model=list[CashFlowCategoryRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_cashflow_categories(db: Session = Depends(get_db)):
|
||||
logger.debug("List cash flow categories called.")
|
||||
return db.query(models.CashFlowCategory).all()
|
||||
|
||||
@app.patch("/cashflow/categories/{category_id}", response_model=CashFlowCategoryRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def update_cashflow_category(category_id: int, category: CashFlowCategoryCreate, db: Session = Depends(get_db)):
|
||||
db_cat = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.id == category_id).first()
|
||||
if not db_cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db_cat.name = category.name
|
||||
db_cat.category_type = category.category_type
|
||||
db_cat.funding_type = category.funding_type
|
||||
db.commit()
|
||||
db.refresh(db_cat)
|
||||
return db_cat
|
||||
|
||||
@app.delete("/cashflow/categories/{category_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_cashflow_category(category_id: int, db: Session = Depends(get_db)):
|
||||
db_cat = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.id == category_id).first()
|
||||
if not db_cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(db_cat)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# --- Cash Flow Entry Endpoints ---
|
||||
@app.post("/cashflow/entries", response_model=CashFlowEntryRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def create_cashflow_entry(entry: CashFlowEntryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_admin_user)):
|
||||
logger.info(f"Create cash flow entry called for category {entry.category_id}, {entry.year}-{entry.month}")
|
||||
db_entry = models.CashFlowEntry(
|
||||
category_id=entry.category_id,
|
||||
year=entry.year,
|
||||
month=entry.month,
|
||||
projected_amount=entry.projected_amount,
|
||||
actual_amount=entry.actual_amount,
|
||||
created_by=user.id
|
||||
)
|
||||
db.add(db_entry)
|
||||
db.commit()
|
||||
db.refresh(db_entry)
|
||||
logger.debug(f"Created cash flow entry: projected={entry.projected_amount}, actual={entry.actual_amount}")
|
||||
return db_entry
|
||||
|
||||
@app.get("/cashflow/entries", response_model=list[CashFlowEntryRead], dependencies=[Depends(get_current_active_user)])
|
||||
def list_cashflow_entries(year: int = None, db: Session = Depends(get_db)):
|
||||
logger.debug(f"List cash flow entries called for year: {year}")
|
||||
q = db.query(models.CashFlowEntry)
|
||||
if year:
|
||||
q = q.filter(models.CashFlowEntry.year == year)
|
||||
return q.all()
|
||||
|
||||
@app.patch("/cashflow/entries/{entry_id}", response_model=CashFlowEntryRead, dependencies=[Depends(get_current_admin_user)])
|
||||
def update_cashflow_entry(entry_id: int, entry: CashFlowEntryCreate, db: Session = Depends(get_db)):
|
||||
db_entry = db.query(models.CashFlowEntry).filter(models.CashFlowEntry.id == entry_id).first()
|
||||
if not db_entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
db_entry.category_id = entry.category_id
|
||||
db_entry.year = entry.year
|
||||
db_entry.month = entry.month
|
||||
db_entry.projected_amount = entry.projected_amount
|
||||
db_entry.actual_amount = entry.actual_amount
|
||||
db.commit()
|
||||
db.refresh(db_entry)
|
||||
logger.debug(f"Updated cash flow entry: projected={entry.projected_amount}, actual={entry.actual_amount}")
|
||||
return db_entry
|
||||
|
||||
@app.delete("/cashflow/entries/{entry_id}", dependencies=[Depends(get_current_admin_user)])
|
||||
def delete_cashflow_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||
db_entry = db.query(models.CashFlowEntry).filter(models.CashFlowEntry.id == entry_id).first()
|
||||
if not db_entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
db.delete(db_entry)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
# --- Forecasting Endpoint ---
|
||||
@app.post("/forecast", response_model=list[schemas.ForecastResponse], dependencies=[Depends(get_current_active_user)])
|
||||
def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(get_db)):
|
||||
logger = logging.getLogger("hoa_app.main")
|
||||
logger.info(f"Forecast balances called for bucket: {getattr(request, 'bucket', None)} year: {getattr(request, 'year', None)}")
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import calendar
|
||||
|
||||
bucket_name = getattr(request, 'bucket', None)
|
||||
year = getattr(request, 'year', None)
|
||||
months = getattr(request, 'months', 12)
|
||||
if not bucket_name or not year:
|
||||
return []
|
||||
|
||||
# 1. Get all accounts in the bucket
|
||||
accounts = db.query(models.Account).join(models.Bucket).filter(models.Bucket.name == bucket_name).all()
|
||||
account_ids = [a.id for a in accounts]
|
||||
account_map = {a.id: a for a in accounts}
|
||||
|
||||
# 2. Get last validated balance for each account
|
||||
validated_balances = {}
|
||||
validated_dates = {}
|
||||
for acc in accounts:
|
||||
validated_balances[acc.id] = acc.balance or 0.0
|
||||
validated_dates[acc.id] = acc.last_validated_at
|
||||
|
||||
# 3. Get all reconciled transactions for these accounts in the forecast year
|
||||
txs = db.query(models.Transaction).filter(
|
||||
models.Transaction.account_id.in_(account_ids),
|
||||
models.Transaction.reconciled == True,
|
||||
models.Transaction.date >= datetime(year, 1, 1),
|
||||
models.Transaction.date < datetime(year + 1, 1, 1)
|
||||
).all()
|
||||
# Also get incoming transfers (where related_account_id is in account_ids)
|
||||
incoming_txs = db.query(models.Transaction).filter(
|
||||
models.Transaction.related_account_id.in_(account_ids),
|
||||
models.Transaction.reconciled == True,
|
||||
models.Transaction.date >= datetime(year, 1, 1),
|
||||
models.Transaction.date < datetime(year + 1, 1, 1)
|
||||
).all()
|
||||
|
||||
# --- NEW: Get all planned (unreconciled) transactions for these accounts in the forecast year ---
|
||||
planned_txs = db.query(models.Transaction).filter(
|
||||
models.Transaction.account_id.in_(account_ids),
|
||||
models.Transaction.reconciled == False,
|
||||
models.Transaction.date >= datetime(year, 1, 1),
|
||||
models.Transaction.date < datetime(year + 1, 1, 1)
|
||||
).all()
|
||||
planned_incoming_txs = db.query(models.Transaction).filter(
|
||||
models.Transaction.related_account_id.in_(account_ids),
|
||||
models.Transaction.reconciled == False,
|
||||
models.Transaction.date >= datetime(year, 1, 1),
|
||||
models.Transaction.date < datetime(year + 1, 1, 1)
|
||||
).all()
|
||||
|
||||
# 4. Organize transactions by (account_id, month)
|
||||
txs_by_year_month = defaultdict(list)
|
||||
for tx in txs:
|
||||
tx_year = tx.date.year
|
||||
tx_month = tx.date.month
|
||||
txs_by_year_month[(tx.account_id, tx_year, tx_month)].append(tx)
|
||||
for tx in incoming_txs:
|
||||
tx_year = tx.date.year
|
||||
tx_month = tx.date.month
|
||||
# incoming transfer: add to related_account_id
|
||||
txs_by_year_month[(tx.related_account_id, tx_year, tx_month)].append(tx)
|
||||
|
||||
# Add planned transactions to the same structure, with debug logging
|
||||
for tx in planned_txs:
|
||||
tx_year = tx.date.year
|
||||
tx_month = tx.date.month
|
||||
logger.debug(f"Including planned (unreconciled) transaction for account {tx.account_id} in month {tx_month}: {tx.type} {tx.amount}")
|
||||
txs_by_year_month[(tx.account_id, tx_year, tx_month)].append(tx)
|
||||
for tx in planned_incoming_txs:
|
||||
tx_year = tx.date.year
|
||||
tx_month = tx.date.month
|
||||
logger.debug(f"Including planned (unreconciled) incoming transaction for related_account {tx.related_account_id} in month {tx_month}: {tx.type} {tx.amount}")
|
||||
txs_by_year_month[(tx.related_account_id, tx_year, tx_month)].append(tx)
|
||||
|
||||
# Identify primary account for alerting and cash flow application
|
||||
primary_account_id = None
|
||||
primary_account = None
|
||||
if bucket_name.lower() == "operating":
|
||||
for acc in accounts:
|
||||
if "5345" in acc.name:
|
||||
primary_account_id = acc.id
|
||||
primary_account = acc
|
||||
break
|
||||
elif bucket_name.lower() == "reserve":
|
||||
for acc in accounts:
|
||||
if "savings" in acc.name.lower():
|
||||
primary_account_id = acc.id
|
||||
primary_account = acc
|
||||
break
|
||||
alert_threshold = 1000.0
|
||||
# 2. Get historical balances for each account
|
||||
history_by_account = defaultdict(list)
|
||||
histories = db.query(models.AccountBalanceHistory).filter(models.AccountBalanceHistory.account_id.in_(account_ids)).order_by(models.AccountBalanceHistory.date.asc()).all()
|
||||
for h in histories:
|
||||
history_by_account[h.account_id].append(h)
|
||||
|
||||
# --- Cash Flow Projections: Only for Operating bucket ---
|
||||
cashflow_by_year_month = defaultdict(float)
|
||||
if bucket_name.lower() == "operating" and primary_account_id is not None:
|
||||
# Get all cash flow categories for Operating
|
||||
op_categories = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.funding_type == models.CashFlowFundingTypeEnum.operating).all()
|
||||
op_cat_ids = [cat.id for cat in op_categories]
|
||||
cat_type_map = {cat.id: cat.category_type for cat in op_categories}
|
||||
# Get all cash flow entries for all years for these categories
|
||||
cf_entries = db.query(models.CashFlowEntry).filter(
|
||||
models.CashFlowEntry.category_id.in_(op_cat_ids)
|
||||
).all()
|
||||
for entry in cf_entries:
|
||||
if entry.projected_amount is None:
|
||||
continue
|
||||
key = (entry.year, entry.month)
|
||||
if cat_type_map.get(entry.category_id) == models.CashFlowCategoryTypeEnum.income:
|
||||
cashflow_by_year_month[key] += entry.projected_amount
|
||||
elif cat_type_map.get(entry.category_id) == models.CashFlowCategoryTypeEnum.expense:
|
||||
cashflow_by_year_month[key] -= entry.projected_amount
|
||||
for m in range(1, months + 1):
|
||||
forecast_year = year + (m - 1) // 12
|
||||
forecast_month = ((m - 1) % 12) + 1
|
||||
logger.debug(f"Cash flow for {forecast_year}-{forecast_month:02d}: {cashflow_by_year_month[(forecast_year, forecast_month)]}")
|
||||
|
||||
# 5. Build forecast for each account
|
||||
responses = []
|
||||
for acc in accounts:
|
||||
forecast_points = []
|
||||
# Build a map of (account_id, year, month) -> latest balance for that month
|
||||
monthly_actuals = {}
|
||||
for h in history_by_account[acc.id]:
|
||||
y, m = h.date.year, h.date.month
|
||||
key = (y, m)
|
||||
if key not in monthly_actuals or h.date > monthly_actuals[key][1]:
|
||||
monthly_actuals[key] = (h.balance, h.date)
|
||||
# Only keep the balance value
|
||||
monthly_actuals = {k: v[0] for k, v in monthly_actuals.items()}
|
||||
# Find the earliest month with a historical balance in the forecast year
|
||||
earliest_actual_month = None
|
||||
if monthly_actuals:
|
||||
earliest_actual_month = min([m for (y, m) in monthly_actuals.keys() if y == year], default=None)
|
||||
# --- CD-specific logic (must happen before balance determination) ---
|
||||
is_cd = hasattr(acc, 'account_type') and acc.account_type and acc.account_type.name.lower() == 'cd'
|
||||
funding_month = None
|
||||
funding_amount = 0.0
|
||||
if is_cd:
|
||||
# Find first reconciled deposit/transfer into this CD in the forecast year
|
||||
cd_funding_tx = None
|
||||
for m in range(1, months + 1):
|
||||
month_txs = txs_by_year_month.get((acc.id, year, m), [])
|
||||
for tx in month_txs:
|
||||
if tx.type in ('deposit', 'transfer'):
|
||||
cd_funding_tx = tx
|
||||
break
|
||||
if cd_funding_tx:
|
||||
funding_month = cd_funding_tx.date.month
|
||||
funding_amount = cd_funding_tx.amount
|
||||
logger.debug(f"CD funding for {acc.name} (id={acc.id}) in month {funding_month}: amount={funding_amount}")
|
||||
break
|
||||
# For CDs, always start with $0 balance - they don't exist before funding
|
||||
balance = 0.0
|
||||
logger.debug(f"[{acc.name}] CD account, using $0 starting balance (will be funded in month {funding_month if funding_month else 'unknown'})")
|
||||
|
||||
# --- Find the latest balance from any previous year as starting point ---
|
||||
latest_prev_balance = None
|
||||
latest_prev_date = None
|
||||
for (y, m), bal in monthly_actuals.items():
|
||||
if y < year:
|
||||
if latest_prev_date is None or (y > latest_prev_date[0] or (y == latest_prev_date[0] and m > latest_prev_date[1])):
|
||||
latest_prev_balance = bal
|
||||
latest_prev_date = (y, m)
|
||||
# --- Find the ending balance from the previous forecast period (Dec of previous year) ---
|
||||
prev_dec_balance = None
|
||||
if (year-1, 12) in monthly_actuals:
|
||||
prev_dec_balance = monthly_actuals[(year-1, 12)]
|
||||
# Determine starting balance for forecast (skip for CDs since we already set them to $0)
|
||||
if not is_cd:
|
||||
if earliest_actual_month == 1 and (year, 1) in monthly_actuals:
|
||||
# If there is an actual for Jan of this year, use it
|
||||
balance = monthly_actuals[(year, 1)]
|
||||
logger.debug(f"[{acc.name}] Using actual balance for Jan {year}: {balance}")
|
||||
elif prev_dec_balance is not None:
|
||||
balance = prev_dec_balance
|
||||
logger.debug(f"[{acc.name}] Using Dec {year-1} ending balance as starting balance for {year}: {balance}")
|
||||
elif latest_prev_balance is not None:
|
||||
balance = latest_prev_balance
|
||||
logger.debug(f"[{acc.name}] Using latest available balance from previous years ({latest_prev_date[0]}-{latest_prev_date[1]}): {balance}")
|
||||
else:
|
||||
# Universal rule: If no historical data, start with $0
|
||||
balance = 0.0
|
||||
logger.debug(f"[{acc.name}] No prior year balance found, using $0 starting balance")
|
||||
# For CDs, balance is already set to $0 above
|
||||
alert = False
|
||||
validated_date = validated_dates[acc.id]
|
||||
|
||||
for m in range(1, months + 1):
|
||||
# Calculate the correct year and month for this forecast point
|
||||
forecast_year = year + (m - 1) // 12
|
||||
forecast_month = ((m - 1) % 12) + 1
|
||||
month_date = datetime(forecast_year, forecast_month, 1)
|
||||
key = (forecast_year, forecast_month)
|
||||
# --- Universal rule: For months before the first validated balance, use $0 ---
|
||||
if earliest_actual_month is not None and forecast_year == year and forecast_month < earliest_actual_month:
|
||||
bal = 0.0
|
||||
logger.debug(f"[{acc.name}] month {forecast_month}: Using $0 balance (before first validated balance in month {earliest_actual_month})")
|
||||
balance = bal
|
||||
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
|
||||
continue
|
||||
# --- CD logic: $0 until funded, even if historical ---
|
||||
if is_cd and (funding_month is None or forecast_month < funding_month):
|
||||
bal = 0.0
|
||||
logger.debug(f"CD {acc.name} (id={acc.id}) month {forecast_month}: Forcing balance to $0 (before funding month {funding_month})")
|
||||
balance = bal
|
||||
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
|
||||
continue
|
||||
# Use actual if available (for non-CDs, or for CDs after funding)
|
||||
if key in monthly_actuals:
|
||||
bal = monthly_actuals[key]
|
||||
balance = bal
|
||||
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
|
||||
else:
|
||||
# Projected: start from last known balance (which may be from previous year)
|
||||
if balance is None:
|
||||
bal = 0.0
|
||||
else:
|
||||
bal = balance
|
||||
# Apply transactions for this month
|
||||
for tx in txs_by_year_month.get((acc.id, forecast_year, forecast_month), []):
|
||||
if tx.type == "deposit":
|
||||
bal += tx.amount
|
||||
elif tx.type == "withdrawal":
|
||||
bal -= tx.amount
|
||||
elif tx.type == "transfer":
|
||||
if tx.account_id == acc.id:
|
||||
bal -= tx.amount
|
||||
elif tx.related_account_id == acc.id:
|
||||
bal += tx.amount
|
||||
# --- Apply cash flow projection to primary account (Operating only) ---
|
||||
if bucket_name.lower() == "operating" and acc.id == primary_account_id:
|
||||
cf = cashflow_by_year_month.get((forecast_year, forecast_month), 0.0)
|
||||
if cf != 0.0:
|
||||
logger.debug(f"Applying cash flow projection to primary account {acc.name} (id={acc.id}) {forecast_year}-{forecast_month:02d}: {cf}")
|
||||
bal += cf
|
||||
# --- Apply interest if applicable ---
|
||||
if acc.interest_rate and acc.interest_rate > 0:
|
||||
interest = bal * (acc.interest_rate / 100.0) / 12.0
|
||||
bal += interest
|
||||
logger.debug(f"Interest applied to {acc.name} (id={acc.id}) month {forecast_month}: rate={acc.interest_rate}%, interest={interest:.2f}, new balance={bal:.2f}")
|
||||
balance = bal
|
||||
forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal})
|
||||
if acc.id == primary_account_id and bal < alert_threshold:
|
||||
alert = True
|
||||
responses.append(schemas.ForecastResponse(
|
||||
account_id=acc.id,
|
||||
forecast=[schemas.ForecastPoint(**pt) for pt in forecast_points],
|
||||
is_primary=(acc.id == primary_account_id),
|
||||
alert=alert,
|
||||
account_name=acc.name,
|
||||
account_type=acc.account_type.name if hasattr(acc, 'account_type') and acc.account_type else None
|
||||
))
|
||||
logger.info(f"Forecast complete for bucket: {bucket_name} year: {year}")
|
||||
return responses
|
||||
|
||||
# --- Reporting Endpoint ---
|
||||
@app.post("/report", response_model=ReportResponse, dependencies=[Depends(get_current_active_user)])
|
||||
def report(request: ReportRequest, db: Session = Depends(get_db)):
|
||||
logger.info(f"Report called for start: {request.start_date}, end: {request.end_date}")
|
||||
start = datetime.strptime(request.start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(request.end_date, "%Y-%m-%d")
|
||||
query = db.query(models.Transaction)
|
||||
if request.account_id:
|
||||
query = query.filter(models.Transaction.account_id == request.account_id)
|
||||
elif request.bucket_id:
|
||||
account_ids = [a.id for a in db.query(models.Account).filter(models.Account.bucket_id == request.bucket_id)]
|
||||
query = query.filter(models.Transaction.account_id.in_(account_ids))
|
||||
query = query.filter(and_(models.Transaction.date >= start, models.Transaction.date <= end))
|
||||
transactions = query.all()
|
||||
entries = []
|
||||
for t in transactions:
|
||||
entries.append(ReportEntry(
|
||||
date=t.date.isoformat() if isinstance(t.date, datetime) else str(t.date),
|
||||
description=t.description or "",
|
||||
amount=t.amount,
|
||||
type=t.type,
|
||||
balance=None
|
||||
))
|
||||
logger.info(f"Report generated with {len(entries)} entries.")
|
||||
return ReportResponse(entries=entries)
|
||||
149
backend/backend/hoa_app/models.py
Normal file
149
backend/backend/hoa_app/models.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from .logging_config import setup_logging, DEBUG_LOGGING
|
||||
import logging
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
setup_logging()
|
||||
models_logger = logging.getLogger("hoa_app.models")
|
||||
|
||||
class RoleEnum(str, enum.Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(Enum(RoleEnum), unique=True, nullable=False)
|
||||
users = relationship("User", back_populates="role")
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
class Bucket(Base):
|
||||
__tablename__ = "buckets"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(String)
|
||||
accounts = relationship("Account", back_populates="bucket")
|
||||
|
||||
class AccountType(Base):
|
||||
__tablename__ = "account_types"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
accounts = relationship("Account", back_populates="account_type")
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if DEBUG_LOGGING:
|
||||
models_logger.debug(f"AccountType created: {self.name}")
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
bucket_id = Column(Integer, ForeignKey("buckets.id"))
|
||||
account_type_id = Column(Integer, ForeignKey("account_types.id"))
|
||||
name = Column(String, nullable=False)
|
||||
institution_name = Column(String, nullable=False)
|
||||
interest_rate = Column(Float, default=0.0)
|
||||
maturity_date = Column(DateTime, nullable=True)
|
||||
balance = Column(Float, default=0.0)
|
||||
last_validated_at = Column(DateTime, default=datetime.utcnow)
|
||||
bucket = relationship("Bucket", back_populates="accounts")
|
||||
account_type = relationship("AccountType", back_populates="accounts")
|
||||
transactions = relationship("Transaction", back_populates="account", foreign_keys="[Transaction.account_id]")
|
||||
cash_flows = relationship("CashFlow", back_populates="account")
|
||||
balance_history = relationship("AccountBalanceHistory", back_populates="account", cascade="all, delete-orphan")
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if DEBUG_LOGGING:
|
||||
models_logger.debug(f"Account created: {self.name} at {self.institution_name}")
|
||||
|
||||
def set_balance(self, new_balance):
|
||||
if self.balance != new_balance:
|
||||
if DEBUG_LOGGING:
|
||||
models_logger.debug(f"Balance update for account {self.id}: {self.balance} -> {new_balance}")
|
||||
self.balance = new_balance
|
||||
self.last_validated_at = datetime.utcnow()
|
||||
if DEBUG_LOGGING:
|
||||
models_logger.debug(f"last_validated_at updated for account {self.id}: {self.last_validated_at}")
|
||||
|
||||
class TransactionTypeEnum(str, enum.Enum):
|
||||
deposit = "deposit"
|
||||
withdrawal = "withdrawal"
|
||||
transfer = "transfer"
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"))
|
||||
type = Column(Enum(TransactionTypeEnum), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
date = Column(DateTime, default=datetime.utcnow)
|
||||
description = Column(String)
|
||||
related_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
|
||||
reconciled = Column(Boolean, default=False) # NEW FIELD
|
||||
account = relationship("Account", back_populates="transactions", foreign_keys=[account_id])
|
||||
related_account = relationship("Account", foreign_keys=[related_account_id])
|
||||
|
||||
class CashFlowTypeEnum(str, enum.Enum):
|
||||
inflow = "inflow"
|
||||
outflow = "outflow"
|
||||
|
||||
class CashFlow(Base):
|
||||
__tablename__ = "cash_flows"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"))
|
||||
type = Column(Enum(CashFlowTypeEnum), nullable=False)
|
||||
estimate_actual = Column(Boolean, default=True) # True=estimate, False=actual
|
||||
amount = Column(Float, nullable=False)
|
||||
date = Column(DateTime, nullable=False)
|
||||
description = Column(String)
|
||||
account = relationship("Account", back_populates="cash_flows")
|
||||
|
||||
class AccountBalanceHistory(Base):
|
||||
__tablename__ = "account_balance_history"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True)
|
||||
balance = Column(Float, nullable=False)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
account = relationship("Account", back_populates="balance_history")
|
||||
|
||||
class CashFlowCategoryTypeEnum(str, enum.Enum):
|
||||
income = "income"
|
||||
expense = "expense"
|
||||
|
||||
class CashFlowFundingTypeEnum(str, enum.Enum):
|
||||
operating = "Operating"
|
||||
reserve = "Reserve"
|
||||
|
||||
class CashFlowCategory(Base):
|
||||
__tablename__ = "cash_flow_categories"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
category_type = Column(Enum(CashFlowCategoryTypeEnum), nullable=False)
|
||||
funding_type = Column(Enum(CashFlowFundingTypeEnum), nullable=False)
|
||||
entries = relationship("CashFlowEntry", back_populates="category")
|
||||
|
||||
class CashFlowEntry(Base):
|
||||
__tablename__ = "cash_flow_entries"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("cash_flow_categories.id"), nullable=False)
|
||||
year = Column(Integer, nullable=False)
|
||||
month = Column(Integer, nullable=False)
|
||||
projected_amount = Column(Float, nullable=True)
|
||||
actual_amount = Column(Float, nullable=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
category = relationship("CashFlowCategory", back_populates="entries")
|
||||
user = relationship("User")
|
||||
2
backend/backend/hoa_app/nohup.out
Normal file
2
backend/backend/hoa_app/nohup.out
Normal file
@@ -0,0 +1,2 @@
|
||||
INFO: Will watch for changes in these directories: ['/home/pi/HOApro/backend/backend/hoa_app']
|
||||
ERROR: [Errno 98] Address already in use
|
||||
174
backend/backend/hoa_app/schemas.py
Normal file
174
backend/backend/hoa_app/schemas.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from .models import CashFlowCategoryTypeEnum, CashFlowFundingTypeEnum
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class BucketBase(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
class BucketCreate(BucketBase):
|
||||
pass
|
||||
|
||||
class BucketRead(BucketBase):
|
||||
id: int
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class AccountTypeBase(BaseModel):
|
||||
name: str
|
||||
|
||||
class AccountTypeCreate(AccountTypeBase):
|
||||
pass
|
||||
|
||||
class AccountTypeRead(AccountTypeBase):
|
||||
id: int
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class AccountBase(BaseModel):
|
||||
name: str
|
||||
institution_name: str
|
||||
interest_rate: float = 0.0
|
||||
maturity_date: Optional[str] = None
|
||||
balance: float = 0.0
|
||||
account_type_id: int
|
||||
bucket_id: int
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
pass
|
||||
|
||||
class AccountRead(AccountBase):
|
||||
id: int
|
||||
maturity_date: Optional[datetime] = None
|
||||
last_validated_at: Optional[datetime] = None
|
||||
account_type: AccountTypeRead
|
||||
bucket: BucketRead
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TransactionBase(BaseModel):
|
||||
account_id: int
|
||||
type: str
|
||||
amount: float
|
||||
date: Optional[str] = None
|
||||
description: Optional[str] = ""
|
||||
related_account_id: Optional[int] = None
|
||||
reconciled: Optional[bool] = False
|
||||
|
||||
class TransactionCreate(TransactionBase):
|
||||
pass
|
||||
|
||||
class TransactionRead(TransactionBase):
|
||||
id: int
|
||||
date: str | None = None
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class CashFlowBase(BaseModel):
|
||||
account_id: int
|
||||
type: str
|
||||
estimate_actual: bool = True
|
||||
amount: float
|
||||
date: datetime
|
||||
description: Optional[str] = ""
|
||||
|
||||
class CashFlowCreate(CashFlowBase):
|
||||
pass
|
||||
|
||||
class CashFlowRead(CashFlowBase):
|
||||
id: int
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ForecastRequest(BaseModel):
|
||||
bucket: str = Field(..., description="Bucket name (Operating or Reserve)")
|
||||
year: int = Field(..., description="Year for forecasting")
|
||||
months: int = Field(12, gt=0, le=120, description="Number of months to forecast")
|
||||
|
||||
class ForecastPoint(BaseModel):
|
||||
date: str
|
||||
balance: float
|
||||
|
||||
class ForecastResponse(BaseModel):
|
||||
account_id: int
|
||||
forecast: list[ForecastPoint]
|
||||
is_primary: bool = False
|
||||
alert: bool = False
|
||||
account_name: str | None = None
|
||||
account_type: str | None = None
|
||||
|
||||
class ReportRequest(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
bucket_id: int | None = None
|
||||
account_id: int | None = None
|
||||
|
||||
class ReportEntry(BaseModel):
|
||||
date: str
|
||||
description: str
|
||||
amount: float
|
||||
type: str
|
||||
balance: float | None = None
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
entries: list[ReportEntry]
|
||||
|
||||
class AccountBalanceHistoryRead(BaseModel):
|
||||
id: int
|
||||
account_id: int
|
||||
balance: float
|
||||
date: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CashFlowCategoryBase(BaseModel):
|
||||
name: str
|
||||
category_type: CashFlowCategoryTypeEnum
|
||||
funding_type: CashFlowFundingTypeEnum
|
||||
|
||||
class CashFlowCategoryCreate(CashFlowCategoryBase):
|
||||
pass
|
||||
|
||||
class CashFlowCategoryRead(CashFlowCategoryBase):
|
||||
id: int
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CashFlowEntryBase(BaseModel):
|
||||
category_id: int
|
||||
year: int
|
||||
month: int
|
||||
projected_amount: float | None = None
|
||||
actual_amount: float | None = None
|
||||
|
||||
class CashFlowEntryCreate(CashFlowEntryBase):
|
||||
pass
|
||||
|
||||
class CashFlowEntryRead(CashFlowEntryBase):
|
||||
id: int
|
||||
created_by: Optional[int]
|
||||
created_at: Optional[datetime]
|
||||
updated_at: Optional[datetime]
|
||||
category: Optional[CashFlowCategoryRead]
|
||||
class Config:
|
||||
from_attributes = True
|
||||
247
backend/backend/hoa_app/venv/bin/Activate.ps1
Normal file
247
backend/backend/hoa_app/venv/bin/Activate.ps1
Normal file
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
69
backend/backend/hoa_app/venv/bin/activate
Normal file
69
backend/backend/hoa_app/venv/bin/activate
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# you cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# This should detect bash and zsh, which have a hash command that must
|
||||
# be called to get it to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||
hash -r 2> /dev/null
|
||||
fi
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
VIRTUAL_ENV=/home/pi/HOApro/backend/backend/hoa_app/venv
|
||||
export VIRTUAL_ENV
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(venv) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(venv) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# This should detect bash and zsh, which have a hash command that must
|
||||
# be called to get it to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||
hash -r 2> /dev/null
|
||||
fi
|
||||
26
backend/backend/hoa_app/venv/bin/activate.csh
Normal file
26
backend/backend/hoa_app/venv/bin/activate.csh
Normal file
@@ -0,0 +1,26 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /home/pi/HOApro/backend/backend/hoa_app/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(venv) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
backend/backend/hoa_app/venv/bin/activate.fish
Normal file
69
backend/backend/hoa_app/venv/bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/); you cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /home/pi/HOApro/backend/backend/hoa_app/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
||||
end
|
||||
8
backend/backend/hoa_app/venv/bin/alembic
Executable file
8
backend/backend/hoa_app/venv/bin/alembic
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from alembic.config import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/dotenv
Executable file
8
backend/backend/hoa_app/venv/bin/dotenv
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from dotenv.__main__ import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
8
backend/backend/hoa_app/venv/bin/fastapi
Executable file
8
backend/backend/hoa_app/venv/bin/fastapi
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from fastapi.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/mako-render
Executable file
8
backend/backend/hoa_app/venv/bin/mako-render
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from mako.cmd import cmdline
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cmdline())
|
||||
8
backend/backend/hoa_app/venv/bin/pip
Executable file
8
backend/backend/hoa_app/venv/bin/pip
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/pip3
Executable file
8
backend/backend/hoa_app/venv/bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/pip3.11
Executable file
8
backend/backend/hoa_app/venv/bin/pip3.11
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-decrypt
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-decrypt
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import decrypt
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(decrypt())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-encrypt
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-encrypt
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import encrypt
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(encrypt())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-keygen
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-keygen
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import keygen
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(keygen())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-priv2pub
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-priv2pub
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.util import private_to_public
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(private_to_public())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-sign
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-sign
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import sign
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(sign())
|
||||
8
backend/backend/hoa_app/venv/bin/pyrsa-verify
Executable file
8
backend/backend/hoa_app/venv/bin/pyrsa-verify
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import verify
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(verify())
|
||||
1
backend/backend/hoa_app/venv/bin/python
Symbolic link
1
backend/backend/hoa_app/venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
backend/backend/hoa_app/venv/bin/python3
Symbolic link
1
backend/backend/hoa_app/venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
backend/backend/hoa_app/venv/bin/python3.11
Symbolic link
1
backend/backend/hoa_app/venv/bin/python3.11
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
8
backend/backend/hoa_app/venv/bin/uvicorn
Executable file
8
backend/backend/hoa_app/venv/bin/uvicorn
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from uvicorn.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/backend/hoa_app/venv/bin/watchfiles
Executable file
8
backend/backend/hoa_app/venv/bin/watchfiles
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from watchfiles.cli import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
8
backend/backend/hoa_app/venv/bin/websockets
Executable file
8
backend/backend/hoa_app/venv/bin/websockets
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/home/pi/HOApro/backend/backend/hoa_app/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from websockets.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||
|
||||
/* Greenlet object interface */
|
||||
|
||||
#ifndef Py_GREENLETOBJECT_H
|
||||
#define Py_GREENLETOBJECT_H
|
||||
|
||||
|
||||
#include <Python.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* This is deprecated and undocumented. It does not change. */
|
||||
#define GREENLET_VERSION "1.0.0"
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
#define implementation_ptr_t void*
|
||||
#endif
|
||||
|
||||
typedef struct _greenlet {
|
||||
PyObject_HEAD
|
||||
PyObject* weakreflist;
|
||||
PyObject* dict;
|
||||
implementation_ptr_t pimpl;
|
||||
} PyGreenlet;
|
||||
|
||||
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||
|
||||
|
||||
/* C API functions */
|
||||
|
||||
/* Total number of symbols that are exported */
|
||||
#define PyGreenlet_API_pointers 12
|
||||
|
||||
#define PyGreenlet_Type_NUM 0
|
||||
#define PyExc_GreenletError_NUM 1
|
||||
#define PyExc_GreenletExit_NUM 2
|
||||
|
||||
#define PyGreenlet_New_NUM 3
|
||||
#define PyGreenlet_GetCurrent_NUM 4
|
||||
#define PyGreenlet_Throw_NUM 5
|
||||
#define PyGreenlet_Switch_NUM 6
|
||||
#define PyGreenlet_SetParent_NUM 7
|
||||
|
||||
#define PyGreenlet_MAIN_NUM 8
|
||||
#define PyGreenlet_STARTED_NUM 9
|
||||
#define PyGreenlet_ACTIVE_NUM 10
|
||||
#define PyGreenlet_GET_PARENT_NUM 11
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
/* This section is used by modules that uses the greenlet C API */
|
||||
static void** _PyGreenlet_API = NULL;
|
||||
|
||||
# define PyGreenlet_Type \
|
||||
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||
|
||||
# define PyExc_GreenletError \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||
|
||||
# define PyExc_GreenletExit \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_New(PyObject *args)
|
||||
*
|
||||
* greenlet.greenlet(run, parent=None)
|
||||
*/
|
||||
# define PyGreenlet_New \
|
||||
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetCurrent(void)
|
||||
*
|
||||
* greenlet.getcurrent()
|
||||
*/
|
||||
# define PyGreenlet_GetCurrent \
|
||||
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Throw(
|
||||
* PyGreenlet *greenlet,
|
||||
* PyObject *typ,
|
||||
* PyObject *val,
|
||||
* PyObject *tb)
|
||||
*
|
||||
* g.throw(...)
|
||||
*/
|
||||
# define PyGreenlet_Throw \
|
||||
(*(PyObject * (*)(PyGreenlet * self, \
|
||||
PyObject * typ, \
|
||||
PyObject * val, \
|
||||
PyObject * tb)) \
|
||||
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||
*
|
||||
* g.switch(*args, **kwargs)
|
||||
*/
|
||||
# define PyGreenlet_Switch \
|
||||
(*(PyObject * \
|
||||
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||
*
|
||||
* g.parent = new_parent
|
||||
*/
|
||||
# define PyGreenlet_SetParent \
|
||||
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||
*
|
||||
* return greenlet.parent;
|
||||
*
|
||||
* This could return NULL even if there is no exception active.
|
||||
* If it does not return NULL, you are responsible for decrementing the
|
||||
* reference count.
|
||||
*/
|
||||
# define PyGreenlet_GetParent \
|
||||
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||
|
||||
/*
|
||||
* deprecated, undocumented alias.
|
||||
*/
|
||||
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||
|
||||
# define PyGreenlet_MAIN \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||
|
||||
# define PyGreenlet_STARTED \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||
|
||||
# define PyGreenlet_ACTIVE \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||
|
||||
|
||||
|
||||
|
||||
/* Macro that imports greenlet and initializes C API */
|
||||
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||
keep the older definition to be sure older code that might have a copy of
|
||||
the header still works. */
|
||||
# define PyGreenlet_Import() \
|
||||
{ \
|
||||
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||
}
|
||||
|
||||
#endif /* GREENLET_MODULE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* !Py_GREENLETOBJECT_H */
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,28 @@
|
||||
Copyright 2010 Pallets
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,92 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: MarkupSafe
|
||||
Version: 3.0.2
|
||||
Summary: Safely add untrusted strings to HTML/XML markup.
|
||||
Maintainer-email: Pallets <contact@palletsprojects.com>
|
||||
License: Copyright 2010 Pallets
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Project-URL: Donate, https://palletsprojects.com/donate
|
||||
Project-URL: Documentation, https://markupsafe.palletsprojects.com/
|
||||
Project-URL: Changes, https://markupsafe.palletsprojects.com/changes/
|
||||
Project-URL: Source, https://github.com/pallets/markupsafe/
|
||||
Project-URL: Chat, https://discord.gg/pallets
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Environment :: Web Environment
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: BSD License
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
||||
Classifier: Topic :: Text Processing :: Markup :: HTML
|
||||
Classifier: Typing :: Typed
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE.txt
|
||||
|
||||
# MarkupSafe
|
||||
|
||||
MarkupSafe implements a text object that escapes characters so it is
|
||||
safe to use in HTML and XML. Characters that have special meanings are
|
||||
replaced so that they display as the actual characters. This mitigates
|
||||
injection attacks, meaning untrusted user input can safely be displayed
|
||||
on a page.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
```pycon
|
||||
>>> from markupsafe import Markup, escape
|
||||
|
||||
>>> # escape replaces special characters and wraps in Markup
|
||||
>>> escape("<script>alert(document.cookie);</script>")
|
||||
Markup('<script>alert(document.cookie);</script>')
|
||||
|
||||
>>> # wrap in Markup to mark text "safe" and prevent escaping
|
||||
>>> Markup("<strong>Hello</strong>")
|
||||
Markup('<strong>hello</strong>')
|
||||
|
||||
>>> escape(Markup("<strong>Hello</strong>"))
|
||||
Markup('<strong>hello</strong>')
|
||||
|
||||
>>> # Markup is a str subclass
|
||||
>>> # methods and operators escape their arguments
|
||||
>>> template = Markup("Hello <em>{name}</em>")
|
||||
>>> template.format(name='"World"')
|
||||
Markup('Hello <em>"World"</em>')
|
||||
```
|
||||
|
||||
## Donate
|
||||
|
||||
The Pallets organization develops and supports MarkupSafe and other
|
||||
popular packages. In order to grow the community of contributors and
|
||||
users, and allow the maintainers to devote more time to the projects,
|
||||
[please donate today][].
|
||||
|
||||
[please donate today]: https://palletsprojects.com/donate
|
||||
@@ -0,0 +1,15 @@
|
||||
MarkupSafe-3.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
MarkupSafe-3.0.2.dist-info/LICENSE.txt,sha256=SJqOEQhQntmKN7uYPhHg9-HTHwvY-Zp5yESOf_N9B-o,1475
|
||||
MarkupSafe-3.0.2.dist-info/METADATA,sha256=aAwbZhSmXdfFuMM-rEHpeiHRkBOGESyVLJIuwzHP-nw,3975
|
||||
MarkupSafe-3.0.2.dist-info/RECORD,,
|
||||
MarkupSafe-3.0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
MarkupSafe-3.0.2.dist-info/WHEEL,sha256=Op2RVjKCU4Yd3uty1Wlljkjcwas4cTvIrdqkKFZWK28,153
|
||||
MarkupSafe-3.0.2.dist-info/top_level.txt,sha256=qy0Plje5IJuvsCBjejJyhDCjEAdcDLK_2agVcex8Z6U,11
|
||||
markupsafe/__init__.py,sha256=sr-U6_27DfaSrj5jnHYxWN-pvhM27sjlDplMDPZKm7k,13214
|
||||
markupsafe/__pycache__/__init__.cpython-311.pyc,,
|
||||
markupsafe/__pycache__/_native.cpython-311.pyc,,
|
||||
markupsafe/_native.py,sha256=hSLs8Jmz5aqayuengJJ3kdT5PwNpBWpKrmQSdipndC8,210
|
||||
markupsafe/_speedups.c,sha256=O7XulmTo-epI6n2FtMVOrJXl8EAaIwD2iNYmBI5SEoQ,4149
|
||||
markupsafe/_speedups.cpython-311-aarch64-linux-gnu.so,sha256=ERBcuz-gl_TnODv5KWmFWXAr45_JjnsouJnevCcUXlc,98536
|
||||
markupsafe/_speedups.pyi,sha256=ENd1bYe7gbBUf2ywyYWOGUpnXOHNJ-cgTNqetlW8h5k,41
|
||||
markupsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
@@ -0,0 +1,6 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: setuptools (75.2.0)
|
||||
Root-Is-Purelib: false
|
||||
Tag: cp311-cp311-manylinux_2_17_aarch64
|
||||
Tag: cp311-cp311-manylinux2014_aarch64
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
markupsafe
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user