From a70bdeaa5e437e5043bd79365c6dbbb21634e76d Mon Sep 17 00:00:00 2001 From: Johnny 5 Date: Sun, 7 Jun 2026 19:19:46 +0000 Subject: [PATCH] Issue 1: repo scaffold and project structure --- .gitignore | 18 + README.md | 48 + SPEC.md | 25 + backend/alembic.ini | 119 + backend/alembic/README | 1 + backend/alembic/env.py | 59 + backend/alembic/script.py.mako | 29 + .../versions/2792c4ff4612_initial_schema.py | 118 + backend/pyproject.toml | 24 + backend/ten31portal/__init__.py | 1 + backend/ten31portal/audit.py | 29 + backend/ten31portal/auth.py | 51 + backend/ten31portal/cli.py | 59 + backend/ten31portal/config.py | 7 + backend/ten31portal/database.py | 13 + backend/ten31portal/db_init.py | 15 + backend/ten31portal/main.py | 47 + backend/ten31portal/models.py | 127 + backend/ten31portal/routers/__init__.py | 1 + backend/ten31portal/routers/audit_router.py | 31 + backend/ten31portal/routers/auth_router.py | 37 + backend/ten31portal/routers/entity_router.py | 69 + backend/ten31portal/routers/holding_router.py | 83 + backend/ten31portal/routers/import_router.py | 437 +++ .../ten31portal/routers/position_router.py | 117 + backend/ten31portal/routers/round_router.py | 221 ++ backend/ten31portal/schemas.py | 148 + deploy/README.md | 3 + frontend/.gitignore | 24 + frontend/README.md | 73 + frontend/eslint.config.js | 22 + frontend/index.html | 13 + frontend/package-lock.json | 3132 +++++++++++++++++ frontend/package.json | 33 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 46 + frontend/src/api.ts | 172 + frontend/src/components/Layout.tsx | 73 + frontend/src/context/AuthContext.tsx | 42 + frontend/src/format.ts | 74 + frontend/src/index.css | 1 + frontend/src/main.tsx | 10 + frontend/src/pages/EntitiesList.tsx | 148 + frontend/src/pages/EntityOverview.tsx | 152 + frontend/src/pages/Investments.tsx | 233 ++ frontend/src/pages/Login.tsx | 61 + frontend/src/pages/ValuationWorkflow.tsx | 411 +++ frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 12 + 52 files changed, 6750 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/2792c4ff4612_initial_schema.py create mode 100644 backend/pyproject.toml create mode 100644 backend/ten31portal/__init__.py create mode 100644 backend/ten31portal/audit.py create mode 100644 backend/ten31portal/auth.py create mode 100644 backend/ten31portal/cli.py create mode 100644 backend/ten31portal/config.py create mode 100644 backend/ten31portal/database.py create mode 100644 backend/ten31portal/db_init.py create mode 100644 backend/ten31portal/main.py create mode 100644 backend/ten31portal/models.py create mode 100644 backend/ten31portal/routers/__init__.py create mode 100644 backend/ten31portal/routers/audit_router.py create mode 100644 backend/ten31portal/routers/auth_router.py create mode 100644 backend/ten31portal/routers/entity_router.py create mode 100644 backend/ten31portal/routers/holding_router.py create mode 100644 backend/ten31portal/routers/import_router.py create mode 100644 backend/ten31portal/routers/position_router.py create mode 100644 backend/ten31portal/routers/round_router.py create mode 100644 backend/ten31portal/schemas.py create mode 100644 deploy/README.md create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/format.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/EntitiesList.tsx create mode 100644 frontend/src/pages/EntityOverview.tsx create mode 100644 frontend/src/pages/Investments.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/ValuationWorkflow.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a93a42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +*.egg-info/ +dist/ + +# Node +node_modules/ +frontend/dist/ + +# DB +*.db + +# IDE +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c8d5f6 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Ten31Portal + +Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off. + +## Prerequisites + +- Python 3.11+ +- Node.js 20+ + +## Backend + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -e . +uvicorn ten31portal.main:app --reload --port 8000 +``` + +Health check: `GET http://localhost:8000/api/health` + +## Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Opens at `http://localhost:5173`. Proxies `/api` to the backend on port 8000. + +## Project structure + +``` +ten31portal/ + backend/ + ten31portal/ # FastAPI application + main.py # App object and health endpoint + config.py # Env-based configuration + pyproject.toml + frontend/ + src/ + App.tsx # Main component + main.tsx # Entry point + vite.config.ts + deploy/ # StartOS packaging (Issue 17) + SPEC.md # v1 issue specs +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..a294062 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,25 @@ +# Ten31Portal — v1 Issue Specs + +Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off. Replaces Carta for internal use. Self-hosted on StartOS. + +## Stack (baked in, override if you disagree) + +- Backend: FastAPI, SQLModel, SQLite, Alembic migrations +- Frontend: React, Vite, Tailwind +- Auth: session cookies, server-side sessions, bcrypt or argon2 password hashing +- Money: stored as integer cents. Never float in the DB. Format to dollars only at the view layer. +- Dates: `investment_date` is a date. Valuation quarter is stored as a quarter-end date (e.g. 2026-06-30). +- Packaging: StartOS 0.4.0 service, single data volume, platform backups cover the DB. + +## Conventions (every issue) + +- One concern per commit. YAGNI. No speculative abstraction. +- Type hints everywhere. Pydantic/SQLModel for all shapes crossing a boundary. +- No PRs. Build directly. +- House style in any user-facing copy: short sentences, no hype, no em-dashes. + +## Scope guard for all of v1 + +In: entities, holdings, positions, valuation rounds, sign-off workflow, audit log, four roles, CSV import, three read views, one workflow view, StartOS packaging. + +Out (phase 2, do not build): LP access and per-LP isolation, the partner / capital-account side (capital calls, allocations, K-1s), IRR / TVPI / DPI / RVPI computation, BTC or sats denomination, charts. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..0fbe2c2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = 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. +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 alembic/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 "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_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 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[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 diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0b70e43 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,59 @@ +"""Alembic environment configuration.""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from ten31portal.config import DB_PATH + +# Import all models so metadata is populated +from ten31portal.models import ( # noqa: F401 + User, Entity, Holding, Position, + ValuationRound, Valuation, AuditLog, +) + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + url = f"sqlite:///{DB_PATH}" + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + cfg = config.get_section(config.config_ini_section, {}) + cfg["sqlalchemy.url"] = f"sqlite:///{DB_PATH}" + connectable = engine_from_config( + cfg, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..81f5923 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${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 +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[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"} diff --git a/backend/alembic/versions/2792c4ff4612_initial_schema.py b/backend/alembic/versions/2792c4ff4612_initial_schema.py new file mode 100644 index 0000000..1bfd884 --- /dev/null +++ b/backend/alembic/versions/2792c4ff4612_initial_schema.py @@ -0,0 +1,118 @@ +"""initial schema + +Revision ID: 2792c4ff4612 +Revises: +Create Date: 2026-06-07 19:03:02.057772 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '2792c4ff4612' +down_revision: Union[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('entities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('type', sa.Enum('fund', 'spv', 'gp', 'mgmt_co', name='entitytype'), nullable=False), + sa.Column('vintage_year', sa.Integer(), nullable=True), + sa.Column('fund_size_cents', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('active', 'closed', name='entitystatus'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('role', sa.Enum('approver', 'cfo', 'fund_admin', 'viewer', name='userrole'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('audit_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('actor_user_id', sa.Integer(), nullable=True), + sa.Column('action', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('object_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('object_id', sa.Integer(), nullable=True), + sa.Column('detail', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('holdings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('valuation_rounds', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('quarter_end', sa.Date(), nullable=False), + sa.Column('status', sa.Enum('draft', 'submitted', 'approved', 'returned', name='roundstatus'), nullable=False), + sa.Column('submitted_by', sa.Integer(), nullable=True), + sa.Column('submitted_at', sa.DateTime(), nullable=True), + sa.Column('approved_by', sa.Integer(), nullable=True), + sa.Column('approved_at', sa.DateTime(), nullable=True), + sa.Column('return_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('is_seed', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['submitted_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('entity_id', 'quarter_end', name='uq_round_entity_quarter') + ) + op.create_table('positions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('holding_id', sa.Integer(), nullable=False), + sa.Column('security_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('investment_date', sa.Date(), nullable=False), + sa.Column('shares', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('cost_cents', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['holding_id'], ['holdings.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('valuations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('round_id', sa.Integer(), nullable=False), + sa.Column('position_id', sa.Integer(), nullable=False), + sa.Column('value_cents', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ), + sa.ForeignKeyConstraint(['round_id'], ['valuation_rounds.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('round_id', 'position_id', name='uq_valuation_round_position') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('valuations') + op.drop_table('positions') + op.drop_table('valuation_rounds') + op.drop_table('holdings') + op.drop_table('audit_log') + op.drop_table('users') + op.drop_table('entities') + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7543f5c --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "ten31portal" +version = "0.1.0" +description = "Internal system of record for Ten31 entities, holdings, and valuations." +requires-python = ">=3.11" +dependencies = [ + "fastapi==0.115.12", + "uvicorn[standard]==0.34.3", + "sqlmodel==0.0.24", + "alembic==1.15.2", + "argon2-cffi==23.1.0", + "python-multipart==0.0.20", + "itsdangerous==2.2.0", + "aiosqlite==0.21.0", + "starlette-session==0.4.3", +] + +[project.scripts] +ten31portal = "ten31portal.main:cli" +ten31portal-cli = "ten31portal.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/backend/ten31portal/__init__.py b/backend/ten31portal/__init__.py new file mode 100644 index 0000000..87dee4f --- /dev/null +++ b/backend/ten31portal/__init__.py @@ -0,0 +1 @@ +# Ten31Portal backend diff --git a/backend/ten31portal/audit.py b/backend/ten31portal/audit.py new file mode 100644 index 0000000..b36ee48 --- /dev/null +++ b/backend/ten31portal/audit.py @@ -0,0 +1,29 @@ +"""Audit log helper. Every state-changing endpoint must call record_audit.""" + +import json +from typing import Any + +from sqlmodel import Session + +from ten31portal.models import AuditLog + + +def record_audit( + session: Session, + actor_user_id: int | None, + action: str, + object_type: str, + object_id: int | None = None, + detail: Any = None, +) -> AuditLog: + """Write one audit log entry and flush it.""" + entry = AuditLog( + actor_user_id=actor_user_id, + action=action, + object_type=object_type, + object_id=object_id, + detail=detail, + ) + session.add(entry) + session.flush() + return entry diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py new file mode 100644 index 0000000..59c5598 --- /dev/null +++ b/backend/ten31portal/auth.py @@ -0,0 +1,51 @@ +"""Authentication, session management, and role enforcement.""" + +from typing import Annotated + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from fastapi import Depends, HTTPException, Request +from sqlmodel import Session, select + +from ten31portal.database import get_session +from ten31portal.models import User, UserRole + +ph = PasswordHasher() + + +def hash_password(password: str) -> str: + return ph.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return ph.verify(password_hash, password) + except VerifyMismatchError: + return False + + +def get_current_user(request: Request, session: Session = Depends(get_session)) -> User: + """FastAPI dependency: extract user from session cookie.""" + user_id = request.session.get("user_id") + if user_id is None: + raise HTTPException(status_code=401, detail="Not authenticated") + user = session.get(User, user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +def require_role(*roles: UserRole): + """Return a dependency that enforces one of the given roles.""" + def checker(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + return checker + + +# Convenience aliases +require_user = get_current_user +require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver) +require_approver = require_role(UserRole.approver) +require_audit_reader = require_role(UserRole.approver, UserRole.cfo) diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py new file mode 100644 index 0000000..3b248ab --- /dev/null +++ b/backend/ten31portal/cli.py @@ -0,0 +1,59 @@ +"""CLI commands for Ten31Portal.""" + +import argparse +import sys + +from sqlmodel import Session, select + +from ten31portal.auth import hash_password +from ten31portal.database import engine +from ten31portal.db_init import run_migrations +from ten31portal.models import User, UserRole + + +def create_user(args: argparse.Namespace) -> None: + """Create a new user account.""" + run_migrations() + + with Session(engine) as session: + existing = session.exec(select(User).where(User.email == args.email)).first() + if existing: + print(f"Error: user with email {args.email} already exists.", file=sys.stderr) + sys.exit(1) + + try: + role = UserRole(args.role) + except ValueError: + print(f"Error: invalid role '{args.role}'. Must be one of: {', '.join(r.value for r in UserRole)}", file=sys.stderr) + sys.exit(1) + + user = User( + name=args.name, + email=args.email, + password_hash=hash_password(args.password), + role=role, + ) + session.add(user) + session.commit() + print(f"Created user: {user.name} ({user.email}) with role {user.role.value}") + + +def main() -> None: + parser = argparse.ArgumentParser(prog="ten31portal-cli") + sub = parser.add_subparsers(dest="command") + + create = sub.add_parser("create-user", help="Provision a new user") + create.add_argument("--name", required=True) + create.add_argument("--email", required=True) + create.add_argument("--role", required=True, choices=[r.value for r in UserRole]) + create.add_argument("--password", required=True) + + args = parser.parse_args() + if args.command == "create-user": + create_user(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/backend/ten31portal/config.py b/backend/ten31portal/config.py new file mode 100644 index 0000000..9a770b5 --- /dev/null +++ b/backend/ten31portal/config.py @@ -0,0 +1,7 @@ +"""Application configuration via environment variables.""" + +import os + + +DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db") +SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production") diff --git a/backend/ten31portal/database.py b/backend/ten31portal/database.py new file mode 100644 index 0000000..27652a7 --- /dev/null +++ b/backend/ten31portal/database.py @@ -0,0 +1,13 @@ +"""Database engine and session management.""" + +from sqlmodel import Session, create_engine + +from ten31portal.config import DB_PATH + +engine = create_engine(f"sqlite:///{DB_PATH}", echo=False) + + +def get_session(): + """FastAPI dependency that yields a database session.""" + with Session(engine) as session: + yield session diff --git a/backend/ten31portal/db_init.py b/backend/ten31portal/db_init.py new file mode 100644 index 0000000..ba3095f --- /dev/null +++ b/backend/ten31portal/db_init.py @@ -0,0 +1,15 @@ +"""Run Alembic migrations to head on startup.""" + +import os +from pathlib import Path + +from alembic import command +from alembic.config import Config + + +def run_migrations() -> None: + """Apply all pending Alembic migrations.""" + backend_dir = Path(__file__).resolve().parent.parent + alembic_cfg = Config(str(backend_dir / "alembic.ini")) + alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic")) + command.upgrade(alembic_cfg, "head") diff --git a/backend/ten31portal/main.py b/backend/ten31portal/main.py new file mode 100644 index 0000000..ae2d05f --- /dev/null +++ b/backend/ten31portal/main.py @@ -0,0 +1,47 @@ +"""Ten31Portal FastAPI application.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware + +from ten31portal.config import SESSION_SECRET +from ten31portal.db_init import run_migrations +from ten31portal.routers.auth_router import router as auth_router +from ten31portal.routers.audit_router import router as audit_router +from ten31portal.routers.entity_router import router as entity_router +from ten31portal.routers.holding_router import router as holding_router +from ten31portal.routers.position_router import router as position_router +from ten31portal.routers.round_router import router as round_router +from ten31portal.routers.import_router import router as import_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + run_migrations() + yield + + +app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) +app.include_router(auth_router) +app.include_router(audit_router) +app.include_router(entity_router) +app.include_router(holding_router) +app.include_router(position_router) +app.include_router(round_router) +app.include_router(import_router) + + +@app.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +def cli() -> None: + import uvicorn + uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True) + + +if __name__ == "__main__": + cli() diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py new file mode 100644 index 0000000..a7626b7 --- /dev/null +++ b/backend/ten31portal/models.py @@ -0,0 +1,127 @@ +"""SQLModel table definitions for Ten31Portal.""" + +import enum +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + +from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint + + +# --- Enums --- + +class UserRole(str, enum.Enum): + approver = "approver" + cfo = "cfo" + fund_admin = "fund_admin" + viewer = "viewer" + + +class EntityType(str, enum.Enum): + fund = "fund" + spv = "spv" + gp = "gp" + mgmt_co = "mgmt_co" + + +class EntityStatus(str, enum.Enum): + active = "active" + closed = "closed" + + +class RoundStatus(str, enum.Enum): + draft = "draft" + submitted = "submitted" + approved = "approved" + returned = "returned" + + +# --- Tables --- + +class User(SQLModel, table=True): + __tablename__ = "users" + + id: int | None = Field(default=None, primary_key=True) + name: str + email: str = Field(sa_column=Column(String, unique=True, nullable=False)) + password_hash: str + role: UserRole + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Entity(SQLModel, table=True): + __tablename__ = "entities" + + id: int | None = Field(default=None, primary_key=True) + name: str + type: EntityType + vintage_year: int | None = None + fund_size_cents: int | None = None + status: EntityStatus = Field(default=EntityStatus.active) + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Holding(SQLModel, table=True): + __tablename__ = "holdings" + + id: int | None = Field(default=None, primary_key=True) + entity_id: int = Field(foreign_key="entities.id") + company_name: str + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Position(SQLModel, table=True): + __tablename__ = "positions" + + id: int | None = Field(default=None, primary_key=True) + holding_id: int = Field(foreign_key="holdings.id") + security_name: str + investment_date: date + shares: str | None = None # Decimal stored as string + cost_cents: int + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class ValuationRound(SQLModel, table=True): + __tablename__ = "valuation_rounds" + __table_args__ = ( + UniqueConstraint("entity_id", "quarter_end", name="uq_round_entity_quarter"), + ) + + id: int | None = Field(default=None, primary_key=True) + entity_id: int = Field(foreign_key="entities.id") + quarter_end: date + status: RoundStatus = Field(default=RoundStatus.draft) + submitted_by: int | None = Field(default=None, foreign_key="users.id") + submitted_at: datetime | None = None + approved_by: int | None = Field(default=None, foreign_key="users.id") + approved_at: datetime | None = None + return_note: str | None = None + is_seed: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Valuation(SQLModel, table=True): + __tablename__ = "valuations" + __table_args__ = ( + UniqueConstraint("round_id", "position_id", name="uq_valuation_round_position"), + ) + + id: int | None = Field(default=None, primary_key=True) + round_id: int = Field(foreign_key="valuation_rounds.id") + position_id: int = Field(foreign_key="positions.id") + value_cents: int + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class AuditLog(SQLModel, table=True): + __tablename__ = "audit_log" + + id: int | None = Field(default=None, primary_key=True) + actor_user_id: int | None = Field(default=None, foreign_key="users.id") + action: str + object_type: str + object_id: int | None = None + detail: str | None = Field(default=None, sa_column=Column(JSON, nullable=True)) + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/ten31portal/routers/__init__.py b/backend/ten31portal/routers/__init__.py new file mode 100644 index 0000000..029175c --- /dev/null +++ b/backend/ten31portal/routers/__init__.py @@ -0,0 +1 @@ +# Router package diff --git a/backend/ten31portal/routers/audit_router.py b/backend/ten31portal/routers/audit_router.py new file mode 100644 index 0000000..76b5392 --- /dev/null +++ b/backend/ten31portal/routers/audit_router.py @@ -0,0 +1,31 @@ +"""Audit log endpoint.""" + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session, select, col + +from ten31portal.auth import require_audit_reader +from ten31portal.database import get_session +from ten31portal.models import AuditLog, User +from ten31portal.schemas import AuditLogResponse + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + + +@router.get("") +def list_audit( + object_type: str | None = None, + object_id: int | None = None, + page: int = Query(default=1, ge=1), + per_page: int = Query(default=50, ge=1, le=200), + user: User = Depends(require_audit_reader), + session: Session = Depends(get_session), +) -> list[AuditLogResponse]: + stmt = select(AuditLog) + if object_type is not None: + stmt = stmt.where(AuditLog.object_type == object_type) + if object_id is not None: + stmt = stmt.where(AuditLog.object_id == object_id) + stmt = stmt.order_by(col(AuditLog.id).desc()) + stmt = stmt.offset((page - 1) * per_page).limit(per_page) + rows = session.exec(stmt).all() + return [AuditLogResponse.model_validate(r, from_attributes=True) for r in rows] diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py new file mode 100644 index 0000000..161d02d --- /dev/null +++ b/backend/ten31portal/routers/auth_router.py @@ -0,0 +1,37 @@ +"""Authentication endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session, select + +from ten31portal.auth import get_current_user, hash_password, verify_password +from ten31portal.database import get_session +from ten31portal.models import User +from ten31portal.schemas import LoginRequest, UserResponse + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login") +def login( + body: LoginRequest, + request: Request, + session: Session = Depends(get_session), +) -> UserResponse: + user = session.exec(select(User).where(User.email == body.email)).first() + if user is None or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid email or password") + if not user.is_active: + raise HTTPException(status_code=401, detail="Account disabled") + request.session["user_id"] = user.id + return UserResponse.model_validate(user, from_attributes=True) + + +@router.post("/logout") +def logout(request: Request) -> dict[str, str]: + request.session.clear() + return {"status": "ok"} + + +@router.get("/me") +def me(user: User = Depends(get_current_user)) -> UserResponse: + return UserResponse.model_validate(user, from_attributes=True) diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py new file mode 100644 index 0000000..b4aabab --- /dev/null +++ b/backend/ten31portal/routers/entity_router.py @@ -0,0 +1,69 @@ +"""Entity CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ten31portal.audit import record_audit +from ten31portal.auth import get_current_user, require_writer +from ten31portal.database import get_session +from ten31portal.models import Entity, EntityStatus, User +from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate + +router = APIRouter(prefix="/api/entities", tags=["entities"]) + + +@router.get("") +def list_entities( + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[EntityResponse]: + rows = session.exec(select(Entity)).all() + return [EntityResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.get("/{entity_id}") +def get_entity( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> EntityResponse: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + return EntityResponse.model_validate(entity, from_attributes=True) + + +@router.post("", status_code=201) +def create_entity( + body: EntityCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> EntityResponse: + entity = Entity(**body.model_dump()) + session.add(entity) + session.flush() + record_audit(session, user.id, "create", "entity", entity.id, body.model_dump()) + session.commit() + session.refresh(entity) + return EntityResponse.model_validate(entity, from_attributes=True) + + +@router.patch("/{entity_id}") +def update_entity( + entity_id: int, + body: EntityUpdate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> EntityResponse: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + changes = body.model_dump(exclude_unset=True) + for key, val in changes.items(): + setattr(entity, key, val) + session.add(entity) + session.flush() + record_audit(session, user.id, "update", "entity", entity.id, changes) + session.commit() + session.refresh(entity) + return EntityResponse.model_validate(entity, from_attributes=True) diff --git a/backend/ten31portal/routers/holding_router.py b/backend/ten31portal/routers/holding_router.py new file mode 100644 index 0000000..c86aee7 --- /dev/null +++ b/backend/ten31portal/routers/holding_router.py @@ -0,0 +1,83 @@ +"""Holding CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ten31portal.audit import record_audit +from ten31portal.auth import get_current_user, require_writer +from ten31portal.database import get_session +from ten31portal.models import Entity, Holding, Position, User +from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate + +router = APIRouter(tags=["holdings"]) + + +@router.get("/api/entities/{entity_id}/holdings") +def list_holdings( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[HoldingResponse]: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + rows = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all() + return [HoldingResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.post("/api/entities/{entity_id}/holdings", status_code=201) +def create_holding( + entity_id: int, + body: HoldingCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> HoldingResponse: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + holding = Holding(entity_id=entity_id, company_name=body.company_name) + session.add(holding) + session.flush() + record_audit(session, user.id, "create", "holding", holding.id, {"entity_id": entity_id, **body.model_dump()}) + session.commit() + session.refresh(holding) + return HoldingResponse.model_validate(holding, from_attributes=True) + + +@router.patch("/api/holdings/{holding_id}") +def update_holding( + holding_id: int, + body: HoldingUpdate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> HoldingResponse: + holding = session.get(Holding, holding_id) + if holding is None: + raise HTTPException(status_code=404, detail="Holding not found") + changes = body.model_dump(exclude_unset=True) + for key, val in changes.items(): + setattr(holding, key, val) + session.add(holding) + session.flush() + record_audit(session, user.id, "update", "holding", holding.id, changes) + session.commit() + session.refresh(holding) + return HoldingResponse.model_validate(holding, from_attributes=True) + + +@router.delete("/api/holdings/{holding_id}") +def delete_holding( + holding_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> dict[str, str]: + holding = session.get(Holding, holding_id) + if holding is None: + raise HTTPException(status_code=404, detail="Holding not found") + positions = session.exec(select(Position).where(Position.holding_id == holding_id)).all() + if positions: + raise HTTPException(status_code=409, detail="Cannot delete holding with existing positions. Remove positions first.") + record_audit(session, user.id, "delete", "holding", holding.id, {"company_name": holding.company_name}) + session.delete(holding) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py new file mode 100644 index 0000000..8ec3e36 --- /dev/null +++ b/backend/ten31portal/routers/import_router.py @@ -0,0 +1,437 @@ +"""CSV import endpoints for entities and schedule of investments.""" + +import csv +import io +import re +from datetime import date, datetime +from typing import Any + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from sqlmodel import Session, select + +from ten31portal.audit import record_audit +from ten31portal.auth import require_role +from ten31portal.database import get_session +from ten31portal.models import ( + Entity, EntityStatus, EntityType, Holding, Position, + User, UserRole, Valuation, ValuationRound, RoundStatus, +) + +router = APIRouter(prefix="/api/import", tags=["import"]) + + +# --- Column maps --- +# These must be confirmed against real Carta CSV exports. +# Mark as UNCONFIRMED until a sample file is validated. + +ENTITY_COLUMN_MAP: dict[str, str] = { + # Carta export header -> model field + # UNCONFIRMED: update these after inspecting a real Carta entities export + "Entity Name": "name", + "Entity Type": "type", + "Vintage Year": "vintage_year", + "Fund Size": "fund_size_cents", +} + +ENTITY_TYPE_MAP: dict[str, EntityType] = { + "Fund": EntityType.fund, + "fund": EntityType.fund, + "SPV": EntityType.spv, + "spv": EntityType.spv, + "GP": EntityType.gp, + "gp": EntityType.gp, + "Mgmt Co": EntityType.mgmt_co, + "mgmt_co": EntityType.mgmt_co, + "Management Company": EntityType.mgmt_co, +} + +SCHEDULE_COLUMN_MAP: dict[str, str] = { + # UNCONFIRMED: update after inspecting a real Carta schedule-of-investments export + "Company": "company_name", + "Security": "security_name", + "Investment Date": "investment_date", + "Shares": "shares", + "Cost": "cost_cents", + "Value": "value_cents", +} + + +def _parse_money(raw: str) -> int | None: + """Parse dollar strings like '$3.3M', '$61.9M', '$1,234,567', '3300000' to cents.""" + if not raw or not raw.strip(): + return None + s = raw.strip().replace(",", "").replace("$", "") + multiplier = 1 + if s.upper().endswith("M"): + multiplier = 1_000_000 + s = s[:-1] + elif s.upper().endswith("K"): + multiplier = 1_000 + s = s[:-1] + elif s.upper().endswith("B"): + multiplier = 1_000_000_000 + s = s[:-1] + try: + return round(float(s) * multiplier * 100) + except ValueError: + return None + + +def _parse_date(raw: str) -> date | None: + """Try common date formats.""" + for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%Y/%m/%d"): + try: + return datetime.strptime(raw.strip(), fmt).date() + except ValueError: + continue + return None + + +def _parse_shares(raw: str) -> str | None: + """Parse share count, return as string (Decimal stored as string).""" + if not raw or not raw.strip(): + return None + s = raw.strip().replace(",", "") + try: + float(s) # Validate it's numeric + return s + except ValueError: + return None + + +# --- Entity import --- + +@router.post("/entities") +def import_entities( + file: UploadFile = File(...), + commit: bool = Query(default=True), + user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), + session: Session = Depends(get_session), +) -> dict[str, Any]: + content = file.file.read().decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(content)) + + results: list[dict] = [] + errors: list[dict] = [] + created = 0 + updated = 0 + + for i, row in enumerate(reader, start=2): # row 1 is header + mapped: dict[str, Any] = {} + unmapped_cols: list[str] = [] + + for csv_col, model_field in ENTITY_COLUMN_MAP.items(): + val = row.get(csv_col) + if val is None: + # Try case-insensitive match + for k, v in row.items(): + if k.strip().lower() == csv_col.lower(): + val = v + break + if val is not None: + mapped[model_field] = val.strip() + else: + unmapped_cols.append(csv_col) + + # Parse fields + parsed: dict[str, Any] = {} + row_errors: list[str] = [] + + name = mapped.get("name") + if not name: + row_errors.append("Missing entity name") + else: + parsed["name"] = name + + type_raw = mapped.get("type", "") + entity_type = ENTITY_TYPE_MAP.get(type_raw) + if entity_type is None and type_raw: + row_errors.append(f"Unknown entity type: {type_raw}") + elif entity_type: + parsed["type"] = entity_type + + vy = mapped.get("vintage_year") + if vy: + try: + parsed["vintage_year"] = int(vy) + except ValueError: + row_errors.append(f"Invalid vintage year: {vy}") + + fs = mapped.get("fund_size_cents") + if fs: + cents = _parse_money(fs) + if cents is None: + row_errors.append(f"Cannot parse fund size: {fs}") + else: + parsed["fund_size_cents"] = cents + + if row_errors: + errors.append({"row": i, "errors": row_errors, "raw": dict(row)}) + continue + + if not parsed.get("name"): + continue + + # Check existing + existing = session.exec(select(Entity).where(Entity.name == parsed["name"])).first() + action = "update" if existing else "create" + + results.append({ + "row": i, + "action": action, + "name": parsed["name"], + "type": parsed.get("type", EntityType.fund).value if parsed.get("type") else None, + "vintage_year": parsed.get("vintage_year"), + "fund_size_cents": parsed.get("fund_size_cents"), + }) + + if commit: + if existing: + if "type" in parsed: + existing.type = parsed["type"] + if "vintage_year" in parsed: + existing.vintage_year = parsed["vintage_year"] + if "fund_size_cents" in parsed: + existing.fund_size_cents = parsed["fund_size_cents"] + session.add(existing) + updated += 1 + else: + entity = Entity( + name=parsed["name"], + type=parsed.get("type", EntityType.fund), + vintage_year=parsed.get("vintage_year"), + fund_size_cents=parsed.get("fund_size_cents"), + ) + session.add(entity) + created += 1 + + if commit: + record_audit(session, user.id, "import_entities", "entity", None, { + "created": created, "updated": updated, "errors": len(errors), + }) + session.commit() + + return { + "committed": commit, + "preview": results, + "errors": errors, + "summary": {"created": created, "updated": updated, "error_rows": len(errors)}, + } + + +# --- Schedule of investments import --- + +@router.post("/schedule") +def import_schedule( + file: UploadFile = File(...), + entity_id: int = Query(...), + as_of: date = Query(...), + commit: bool = Query(default=True), + user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), + session: Session = Depends(get_session), +) -> dict[str, Any]: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + + # Check for existing non-seed round at this quarter + existing_round = session.exec( + select(ValuationRound).where( + ValuationRound.entity_id == entity_id, + ValuationRound.quarter_end == as_of, + ValuationRound.is_seed == False, + ) + ).first() + if existing_round: + raise HTTPException( + status_code=409, + detail="A non-seed valuation round already exists for this entity and quarter.", + ) + + content = file.file.read().decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(content)) + + holdings_preview: list[dict] = [] + positions_preview: list[dict] = [] + errors: list[dict] = [] + current_company: str | None = None + + for i, row in enumerate(reader, start=2): + mapped: dict[str, Any] = {} + for csv_col, model_field in SCHEDULE_COLUMN_MAP.items(): + val = row.get(csv_col) + if val is None: + for k, v in row.items(): + if k.strip().lower() == csv_col.lower(): + val = v + break + if val is not None: + mapped[model_field] = val.strip() + + company = mapped.get("company_name", "").strip() + security = mapped.get("security_name", "").strip() + + # Grouped layout: company row has company name but may lack security detail + if company and not security: + current_company = company + holdings_preview.append({"row": i, "company_name": company}) + continue + + # Position row: may inherit company from grouping + if not company and current_company: + company = current_company + elif company: + # Both company and security on same row + if company not in [h["company_name"] for h in holdings_preview]: + holdings_preview.append({"row": i, "company_name": company}) + current_company = company + + if not security: + continue # Skip empty/subtotal rows + + row_errors: list[str] = [] + + inv_date = None + if mapped.get("investment_date"): + inv_date = _parse_date(mapped["investment_date"]) + if inv_date is None: + row_errors.append(f"Cannot parse date: {mapped['investment_date']}") + + shares = _parse_shares(mapped.get("shares", "")) + + cost_cents = None + if mapped.get("cost_cents"): + cost_cents = _parse_money(mapped["cost_cents"]) + if cost_cents is None: + row_errors.append(f"Cannot parse cost: {mapped['cost_cents']}") + + value_cents = None + if mapped.get("value_cents"): + value_cents = _parse_money(mapped["value_cents"]) + if value_cents is None: + row_errors.append(f"Cannot parse value: {mapped['value_cents']}") + + if row_errors: + errors.append({"row": i, "errors": row_errors, "raw": dict(row)}) + continue + + positions_preview.append({ + "row": i, + "company_name": company, + "security_name": security, + "investment_date": str(inv_date) if inv_date else None, + "shares": shares, + "cost_cents": cost_cents, + "value_cents": value_cents, + }) + + if not commit: + return { + "committed": False, + "holdings": holdings_preview, + "positions": positions_preview, + "errors": errors, + "seed_round": {"entity_id": entity_id, "quarter_end": str(as_of)}, + } + + # Commit: create holdings, positions, seed round + holding_map: dict[str, Holding] = {} + + for hp in holdings_preview: + name = hp["company_name"] + existing = session.exec( + select(Holding).where( + Holding.entity_id == entity_id, + Holding.company_name == name, + ) + ).first() + if existing: + holding_map[name] = existing + else: + h = Holding(entity_id=entity_id, company_name=name) + session.add(h) + session.flush() + holding_map[name] = h + + # Create seed round + seed_round = ValuationRound( + entity_id=entity_id, + quarter_end=as_of, + status=RoundStatus.approved, + is_seed=True, + approved_by=user.id, + approved_at=datetime.utcnow(), + ) + session.add(seed_round) + session.flush() + + positions_created = 0 + for pp in positions_preview: + company = pp["company_name"] + holding = holding_map.get(company) + if holding is None: + # Create holding on the fly for positions with inline company + existing = session.exec( + select(Holding).where( + Holding.entity_id == entity_id, + Holding.company_name == company, + ) + ).first() + if existing: + holding = existing + else: + holding = Holding(entity_id=entity_id, company_name=company) + session.add(holding) + session.flush() + holding_map[company] = holding + + # Upsert position by (holding, security_name) + pos = session.exec( + select(Position).where( + Position.holding_id == holding.id, + Position.security_name == pp["security_name"], + ) + ).first() + if pos is None: + pos = Position( + holding_id=holding.id, + security_name=pp["security_name"], + investment_date=_parse_date(pp["investment_date"]) if pp["investment_date"] else as_of, + shares=pp["shares"], + cost_cents=pp["cost_cents"] or 0, + ) + session.add(pos) + session.flush() + positions_created += 1 + else: + if pp["investment_date"]: + pos.investment_date = _parse_date(pp["investment_date"]) + if pp["shares"]: + pos.shares = pp["shares"] + if pp["cost_cents"] is not None: + pos.cost_cents = pp["cost_cents"] + session.add(pos) + session.flush() + + # Attach valuation to seed round + val = Valuation( + round_id=seed_round.id, + position_id=pos.id, + value_cents=pp["value_cents"] or 0, + ) + session.add(val) + + record_audit(session, user.id, "import_schedule", "entity", entity_id, { + "holdings": len(holding_map), + "positions": positions_created, + "seed_quarter": str(as_of), + }) + session.commit() + + return { + "committed": True, + "holdings_count": len(holding_map), + "positions_count": len(positions_preview), + "seed_round_id": seed_round.id, + "errors": errors, + } diff --git a/backend/ten31portal/routers/position_router.py b/backend/ten31portal/routers/position_router.py new file mode 100644 index 0000000..66513e8 --- /dev/null +++ b/backend/ten31portal/routers/position_router.py @@ -0,0 +1,117 @@ +"""Position CRUD endpoints.""" + +import math + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ten31portal.audit import record_audit +from ten31portal.auth import get_current_user, require_writer +from ten31portal.database import get_session +from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User +from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate + +router = APIRouter(tags=["positions"]) + + +def _dollars_to_cents(dollars: float) -> int: + """Convert dollar amount to integer cents, rounding to nearest cent.""" + return round(dollars * 100) + + +@router.get("/api/holdings/{holding_id}/positions") +def list_positions( + holding_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[PositionResponse]: + holding = session.get(Holding, holding_id) + if holding is None: + raise HTTPException(status_code=404, detail="Holding not found") + rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all() + return [PositionResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.post("/api/holdings/{holding_id}/positions", status_code=201) +def create_position( + holding_id: int, + body: PositionCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> PositionResponse: + holding = session.get(Holding, holding_id) + if holding is None: + raise HTTPException(status_code=404, detail="Holding not found") + position = Position( + holding_id=holding_id, + security_name=body.security_name, + investment_date=body.investment_date, + shares=body.shares, + cost_cents=_dollars_to_cents(body.cost_dollars), + ) + session.add(position) + session.flush() + record_audit(session, user.id, "create", "position", position.id, { + "holding_id": holding_id, + "security_name": body.security_name, + "cost_dollars": body.cost_dollars, + }) + session.commit() + session.refresh(position) + return PositionResponse.model_validate(position, from_attributes=True) + + +@router.patch("/api/positions/{position_id}") +def update_position( + position_id: int, + body: PositionUpdate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> PositionResponse: + position = session.get(Position, position_id) + if position is None: + raise HTTPException(status_code=404, detail="Position not found") + changes = body.model_dump(exclude_unset=True) + if "cost_dollars" in changes: + position.cost_cents = _dollars_to_cents(changes.pop("cost_dollars")) + for key, val in changes.items(): + setattr(position, key, val) + session.add(position) + session.flush() + record_audit(session, user.id, "update", "position", position.id, body.model_dump(exclude_unset=True)) + session.commit() + session.refresh(position) + return PositionResponse.model_validate(position, from_attributes=True) + + +@router.delete("/api/positions/{position_id}") +def delete_position( + position_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> dict[str, str]: + position = session.get(Position, position_id) + if position is None: + raise HTTPException(status_code=404, detail="Position not found") + + # Refuse if any approved valuation references this position + approved_vals = session.exec( + select(Valuation) + .join(ValuationRound) + .where( + Valuation.position_id == position_id, + ValuationRound.status == RoundStatus.approved, + ) + ).all() + if approved_vals: + raise HTTPException( + status_code=409, + detail="Cannot delete position referenced by an approved valuation round.", + ) + + record_audit(session, user.id, "delete", "position", position.id, { + "security_name": position.security_name, + }) + session.delete(position) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/round_router.py b/backend/ten31portal/routers/round_router.py new file mode 100644 index 0000000..2d51350 --- /dev/null +++ b/backend/ten31portal/routers/round_router.py @@ -0,0 +1,221 @@ +"""Valuation round workflow endpoints.""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, col + +from ten31portal.audit import record_audit +from ten31portal.auth import get_current_user, require_writer, require_approver +from ten31portal.database import get_session +from ten31portal.models import ( + Entity, Holding, Position, Valuation, ValuationRound, + RoundStatus, User, +) +from ten31portal.schemas import ( + RoundCreate, RoundResponse, ValuationBulkUpdate, ValuationResponse, ReturnNote, +) + +router = APIRouter(tags=["rounds"]) + + +def _round_response(round: ValuationRound, session: Session) -> RoundResponse: + vals = session.exec(select(Valuation).where(Valuation.round_id == round.id)).all() + return RoundResponse( + **round.model_dump(), + valuations=[ValuationResponse.model_validate(v, from_attributes=True) for v in vals], + ) + + +@router.get("/api/entities/{entity_id}/rounds") +def list_rounds( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[RoundResponse]: + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + rounds = session.exec( + select(ValuationRound) + .where(ValuationRound.entity_id == entity_id) + .order_by(col(ValuationRound.quarter_end).desc()) + ).all() + return [_round_response(r, session) for r in rounds] + + +@router.get("/api/rounds/{round_id}") +def get_round( + round_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> RoundResponse: + round = session.get(ValuationRound, round_id) + if round is None: + raise HTTPException(status_code=404, detail="Round not found") + return _round_response(round, session) + + +@router.post("/api/entities/{entity_id}/rounds", status_code=201) +def create_round( + entity_id: int, + body: RoundCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> RoundResponse: + """Create a draft round. Pre-populate valuations with last approved marks (carry forward).""" + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + + # Check for existing non-returned round at this quarter + existing = session.exec( + select(ValuationRound).where( + ValuationRound.entity_id == entity_id, + ValuationRound.quarter_end == body.quarter_end, + ValuationRound.status != RoundStatus.returned, + ) + ).first() + if existing: + raise HTTPException(status_code=409, detail="A round already exists for this entity and quarter.") + + round = ValuationRound(entity_id=entity_id, quarter_end=body.quarter_end) + session.add(round) + session.flush() + + # Get all positions for this entity + holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all() + for holding in holdings: + positions = session.exec(select(Position).where(Position.holding_id == holding.id)).all() + for pos in positions: + # Find most recent approved value for this position + last_val = session.exec( + select(Valuation) + .join(ValuationRound) + .where( + Valuation.position_id == pos.id, + ValuationRound.status == RoundStatus.approved, + ) + .order_by(col(ValuationRound.quarter_end).desc()) + ).first() + val = Valuation( + round_id=round.id, + position_id=pos.id, + value_cents=last_val.value_cents if last_val else 0, + ) + session.add(val) + + record_audit(session, user.id, "create", "round", round.id, { + "entity_id": entity_id, + "quarter_end": str(body.quarter_end), + }) + session.commit() + session.refresh(round) + return _round_response(round, session) + + +@router.patch("/api/rounds/{round_id}/valuations") +def bulk_update_valuations( + round_id: int, + body: ValuationBulkUpdate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> RoundResponse: + """Bulk set value_cents for positions in a draft or returned round.""" + round = session.get(ValuationRound, round_id) + if round is None: + raise HTTPException(status_code=404, detail="Round not found") + if round.status not in (RoundStatus.draft, RoundStatus.returned): + raise HTTPException(status_code=409, detail="Can only edit valuations in draft or returned rounds.") + + for item in body.valuations: + val = session.exec( + select(Valuation).where( + Valuation.round_id == round_id, + Valuation.position_id == item.position_id, + ) + ).first() + if val is None: + raise HTTPException(status_code=404, detail=f"Valuation not found for position {item.position_id}") + val.value_cents = item.value_cents + session.add(val) + + record_audit(session, user.id, "update_valuations", "round", round.id, { + "count": len(body.valuations), + }) + session.commit() + session.refresh(round) + return _round_response(round, session) + + +@router.post("/api/rounds/{round_id}/submit") +def submit_round( + round_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> RoundResponse: + round = session.get(ValuationRound, round_id) + if round is None: + raise HTTPException(status_code=404, detail="Round not found") + if round.status not in (RoundStatus.draft, RoundStatus.returned): + raise HTTPException(status_code=409, detail="Only draft or returned rounds can be submitted.") + round.status = RoundStatus.submitted + round.submitted_by = user.id + round.submitted_at = datetime.utcnow() + session.add(round) + record_audit(session, user.id, "submit", "round", round.id, { + "quarter_end": str(round.quarter_end), + }) + session.commit() + session.refresh(round) + return _round_response(round, session) + + +@router.post("/api/rounds/{round_id}/approve") +def approve_round( + round_id: int, + user: User = Depends(require_approver), + session: Session = Depends(get_session), +) -> RoundResponse: + round = session.get(ValuationRound, round_id) + if round is None: + raise HTTPException(status_code=404, detail="Round not found") + if round.status != RoundStatus.submitted: + raise HTTPException(status_code=409, detail="Only submitted rounds can be approved.") + round.status = RoundStatus.approved + round.approved_by = user.id + round.approved_at = datetime.utcnow() + session.add(round) + + # Record both roles if self-approving + detail = {"quarter_end": str(round.quarter_end)} + if round.submitted_by == user.id: + detail["note"] = "Self-approved (submitter and approver are the same user)" + record_audit(session, user.id, "approve", "round", round.id, detail) + session.commit() + session.refresh(round) + return _round_response(round, session) + + +@router.post("/api/rounds/{round_id}/return") +def return_round( + round_id: int, + body: ReturnNote, + user: User = Depends(require_approver), + session: Session = Depends(get_session), +) -> RoundResponse: + round = session.get(ValuationRound, round_id) + if round is None: + raise HTTPException(status_code=404, detail="Round not found") + if round.status != RoundStatus.submitted: + raise HTTPException(status_code=409, detail="Only submitted rounds can be returned.") + round.status = RoundStatus.returned + round.return_note = body.note + session.add(round) + record_audit(session, user.id, "return", "round", round.id, { + "quarter_end": str(round.quarter_end), + "note": body.note, + }) + session.commit() + session.refresh(round) + return _round_response(round, session) diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py new file mode 100644 index 0000000..a5a58c9 --- /dev/null +++ b/backend/ten31portal/schemas.py @@ -0,0 +1,148 @@ +"""Pydantic schemas for API request/response shapes.""" + +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel + +from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus + + +# --- Auth --- + +class LoginRequest(BaseModel): + email: str + password: str + + +class UserResponse(BaseModel): + id: int + name: str + email: str + role: UserRole + is_active: bool + created_at: datetime + + +# --- Entity --- + +class EntityCreate(BaseModel): + name: str + type: EntityType + vintage_year: int | None = None + fund_size_cents: int | None = None + + +class EntityUpdate(BaseModel): + name: str | None = None + type: EntityType | None = None + vintage_year: int | None = None + fund_size_cents: int | None = None + status: EntityStatus | None = None + + +class EntityResponse(BaseModel): + id: int + name: str + type: EntityType + vintage_year: int | None + fund_size_cents: int | None + status: EntityStatus + created_at: datetime + + +# --- Holding --- + +class HoldingCreate(BaseModel): + company_name: str + + +class HoldingUpdate(BaseModel): + company_name: str | None = None + + +class HoldingResponse(BaseModel): + id: int + entity_id: int + company_name: str + created_at: datetime + + +# --- Position --- + +class PositionCreate(BaseModel): + security_name: str + investment_date: date + shares: str | None = None + cost_dollars: float # Accept dollars at API boundary, store as cents + + +class PositionUpdate(BaseModel): + security_name: str | None = None + investment_date: date | None = None + shares: str | None = None + cost_dollars: float | None = None + + +class PositionResponse(BaseModel): + id: int + holding_id: int + security_name: str + investment_date: date + shares: str | None + cost_cents: int + created_at: datetime + + +# --- Valuation Round --- + +class RoundCreate(BaseModel): + quarter_end: date + + +class ValuationBulkItem(BaseModel): + position_id: int + value_cents: int + + +class ValuationBulkUpdate(BaseModel): + valuations: list[ValuationBulkItem] + + +class ReturnNote(BaseModel): + note: str + + +class ValuationResponse(BaseModel): + id: int + round_id: int + position_id: int + value_cents: int + created_at: datetime + + +class RoundResponse(BaseModel): + id: int + entity_id: int + quarter_end: date + status: RoundStatus + submitted_by: int | None + submitted_at: datetime | None + approved_by: int | None + approved_at: datetime | None + return_note: str | None + is_seed: bool + created_at: datetime + valuations: list[ValuationResponse] = [] + + +# --- Audit --- + +class AuditLogResponse(BaseModel): + id: int + actor_user_id: int | None + action: str + object_type: str + object_id: int | None + detail: dict | list | str | None + created_at: datetime diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a876a59 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,3 @@ +# StartOS packaging + +StartOS 0.4.0 service packaging goes here (Issue 17). diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bc44071 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3132 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", + "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "license": "MIT", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..db8a9f6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..bce6884 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,46 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { AuthProvider, useAuth } from "./context/AuthContext"; +import Layout from "./components/Layout"; +import Login from "./pages/Login"; +import EntitiesList from "./pages/EntitiesList"; +import EntityOverview from "./pages/EntityOverview"; +import Investments from "./pages/Investments"; +import ValuationWorkflow from "./pages/ValuationWorkflow"; + +function ProtectedRoutes() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (!user) { + return ; + } + + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..c66b0c2 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,172 @@ +/** + * Typed API client for Ten31Portal backend. + */ + +// --- Types --- + +export type UserRole = "approver" | "cfo" | "fund_admin" | "viewer"; +export type EntityType = "fund" | "spv" | "gp" | "mgmt_co"; +export type EntityStatus = "active" | "closed"; +export type RoundStatus = "draft" | "submitted" | "approved" | "returned"; + +export interface User { + id: number; + name: string; + email: string; + role: UserRole; + is_active: boolean; + created_at: string; +} + +export interface Entity { + id: number; + name: string; + type: EntityType; + vintage_year: number | null; + fund_size_cents: number | null; + status: EntityStatus; + created_at: string; +} + +export interface Holding { + id: number; + entity_id: number; + company_name: string; + created_at: string; +} + +export interface Position { + id: number; + holding_id: number; + security_name: string; + investment_date: string; + shares: string | null; + cost_cents: number; + created_at: string; +} + +export interface Valuation { + id: number; + round_id: number; + position_id: number; + value_cents: number; + created_at: string; +} + +export interface ValuationRound { + id: number; + entity_id: number; + quarter_end: string; + status: RoundStatus; + submitted_by: number | null; + submitted_at: string | null; + approved_by: number | null; + approved_at: string | null; + return_note: string | null; + is_seed: boolean; + created_at: string; + valuations: Valuation[]; +} + +export interface AuditEntry { + id: number; + actor_user_id: number | null; + action: string; + object_type: string; + object_id: number | null; + detail: unknown; + created_at: string; +} + +// --- API helpers --- + +class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new ApiError(res.status, body.detail || res.statusText); + } + return res.json(); +} + +// --- Auth --- + +export const api = { + login: (email: string, password: string) => + request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }), + + logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }), + + me: () => request("/api/auth/me"), + + // Entities + listEntities: () => request("/api/entities"), + getEntity: (id: number) => request(`/api/entities/${id}`), + createEntity: (data: Partial) => + request("/api/entities", { method: "POST", body: JSON.stringify(data) }), + updateEntity: (id: number, data: Partial) => + request(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + + // Holdings + listHoldings: (entityId: number) => + request(`/api/entities/${entityId}/holdings`), + createHolding: (entityId: number, data: { company_name: string }) => + request(`/api/entities/${entityId}/holdings`, { + method: "POST", + body: JSON.stringify(data), + }), + + // Positions + listPositions: (holdingId: number) => + request(`/api/holdings/${holdingId}/positions`), + + // Rounds + listRounds: (entityId: number) => + request(`/api/entities/${entityId}/rounds`), + getRound: (roundId: number) => request(`/api/rounds/${roundId}`), + createRound: (entityId: number, quarterEnd: string) => + request(`/api/entities/${entityId}/rounds`, { + method: "POST", + body: JSON.stringify({ quarter_end: quarterEnd }), + }), + updateValuations: (roundId: number, valuations: { position_id: number; value_cents: number }[]) => + request(`/api/rounds/${roundId}/valuations`, { + method: "PATCH", + body: JSON.stringify({ valuations }), + }), + submitRound: (roundId: number) => + request(`/api/rounds/${roundId}/submit`, { method: "POST" }), + approveRound: (roundId: number) => + request(`/api/rounds/${roundId}/approve`, { method: "POST" }), + returnRound: (roundId: number, note: string) => + request(`/api/rounds/${roundId}/return`, { + method: "POST", + body: JSON.stringify({ note }), + }), + + // Audit + listAudit: (params?: { object_type?: string; object_id?: number; page?: number }) => { + const q = new URLSearchParams(); + if (params?.object_type) q.set("object_type", params.object_type); + if (params?.object_id) q.set("object_id", String(params.object_id)); + if (params?.page) q.set("page", String(params.page)); + return request(`/api/audit?${q}`); + }, +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..ab1e396 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,73 @@ +import { Link, useLocation } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +const NAV_ITEMS = [ + { label: "Entities", path: "/" }, + { label: "Import", path: "/import", stub: true }, + { label: "Audit Log", path: "/audit", stub: true }, +]; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { user, logout } = useAuth(); + const location = useLocation(); + + return ( +
+ {/* Left nav */} + + + {/* Main area */} +
+ {/* Top bar */} +
+
+ {user?.name} + + {user?.role} + + +
+
+ + {/* Content */} +
{children}
+
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..63609e5 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { api, type User } from "../api"; + +interface AuthState { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.me().then(setUser).catch(() => setUser(null)).finally(() => setLoading(false)); + }, []); + + const login = async (email: string, password: string) => { + const u = await api.login(email, password); + setUser(u); + }; + + const logout = async () => { + await api.logout(); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be inside AuthProvider"); + return ctx; +} diff --git a/frontend/src/format.ts b/frontend/src/format.ts new file mode 100644 index 0000000..0181b20 --- /dev/null +++ b/frontend/src/format.ts @@ -0,0 +1,74 @@ +/** + * Money and date formatting helpers. + * Money: cents -> display string. Never float in the DB. + */ + +/** Format cents as dollars: $1.2M, $12,345, etc. */ +export function formatMoney(cents: number | null | undefined): string { + if (cents == null) return "—"; + const dollars = cents / 100; + const abs = Math.abs(dollars); + const sign = dollars < 0 ? "-" : ""; + + if (abs >= 1_000_000_000) { + return `${sign}$${(abs / 1_000_000_000).toFixed(1)}B`; + } + if (abs >= 1_000_000) { + return `${sign}$${(abs / 1_000_000).toFixed(1)}M`; + } + if (abs >= 1_000) { + return `${sign}$${Math.round(abs).toLocaleString("en-US")}`; + } + return `${sign}$${abs.toFixed(2)}`; +} + +/** Format cents as compact dollars for tables (no rounding for small values). */ +export function formatMoneyExact(cents: number | null | undefined): string { + if (cents == null) return "—"; + const dollars = cents / 100; + return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +/** Format date string to short form: Jun 15, 2021 */ +export function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return "—"; + const d = new Date(dateStr + (dateStr.includes("T") ? "" : "T00:00:00")); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +/** Format quarter-end date as "Q2 2026" */ +export function formatQuarter(dateStr: string): string { + const d = new Date(dateStr + "T00:00:00"); + const month = d.getMonth(); // 0-11 + const q = Math.floor(month / 3) + 1; + return `Q${q} ${d.getFullYear()}`; +} + +/** Gain/loss as formatted string with sign */ +export function formatGainLoss(valueCents: number, costCents: number): { + text: string; + positive: boolean; + cents: number; +} { + const diff = valueCents - costCents; + return { + text: formatMoney(Math.abs(diff)), + positive: diff >= 0, + cents: diff, + }; +} + +/** Per-share calculation: cents / shares string. Returns null if shares is null/zero. */ +export function perShare(cents: number, shares: string | null): number | null { + if (!shares) return null; + const s = parseFloat(shares); + if (!s || s === 0) return null; + return cents / s; +} + +/** Format per-share value */ +export function formatPerShare(cents: number, shares: string | null): string { + const val = perShare(cents, shares); + if (val == null) return "—"; + return formatMoneyExact(Math.round(val)); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..10ed13e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/pages/EntitiesList.tsx b/frontend/src/pages/EntitiesList.tsx new file mode 100644 index 0000000..9662968 --- /dev/null +++ b/frontend/src/pages/EntitiesList.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api"; +import { formatMoney, formatGainLoss } from "../format"; + +interface EntityRow extends Entity { + investedCents: number; + lastValueCents: number; +} + +export default function EntitiesList() { + const [entities, setEntities] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + setLoading(true); + try { + const ents = await api.listEntities(); + const enriched: EntityRow[] = []; + + for (const ent of ents) { + const holdings = await api.listHoldings(ent.id); + let investedCents = 0; + let lastValueCents = 0; + + // Get all positions for cost + for (const h of holdings) { + const positions = await api.listPositions(h.id); + for (const p of positions) { + investedCents += p.cost_cents; + } + } + + // Get latest approved round for value + const rounds = await api.listRounds(ent.id); + const approved = rounds.filter((r) => r.status === "approved"); + if (approved.length > 0) { + const latest = approved[0]; // Already sorted desc by quarter_end + lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0); + } + + enriched.push({ ...ent, investedCents, lastValueCents }); + } + + setEntities(enriched); + } finally { + setLoading(false); + } + } + + if (loading) { + return
Loading...
; + } + + const funds = entities.filter((e) => e.type === "fund" || e.type === "spv"); + const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co"); + + return ( +
+ + +
+ ); +} + +function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) { + const totalInvested = rows.reduce((s, r) => s + r.investedCents, 0); + const totalValue = rows.reduce((s, r) => s + r.lastValueCents, 0); + const totalGain = totalValue - totalInvested; + + const TYPE_LABELS: Record = { + fund: "Fund", + spv: "SPV", + gp: "GP", + mgmt_co: "Mgmt Co", + }; + + return ( +
+

{title}

+
+ + + + + + + + + + + + + {rows.map((row) => { + const gl = formatGainLoss(row.lastValueCents, row.investedCents); + return ( + + + + + + + + + ); + })} + {rows.length > 0 && ( + + + + + + + )} + {rows.length === 0 && ( + + + + )} + +
NameTypeVintageInvestedLast Signed ValueGain/Loss
+ + {row.name} + + + + {TYPE_LABELS[row.type] || row.type} + + {row.vintage_year || "—"}{formatMoney(row.investedCents)}{formatMoney(row.lastValueCents)} + {gl.positive ? "+" : "-"}{gl.text} +
+ Total + {formatMoney(totalInvested)}{formatMoney(totalValue)}= 0 ? "text-green-600" : "text-red-600"}`}> + {totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))} +
+ No entities yet. +
+
+
+ ); +} diff --git a/frontend/src/pages/EntityOverview.tsx b/frontend/src/pages/EntityOverview.tsx new file mode 100644 index 0000000..dbb6439 --- /dev/null +++ b/frontend/src/pages/EntityOverview.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api, type Entity, type Holding, type Position, type ValuationRound } from "../api"; +import { formatMoney, formatDate, formatQuarter } from "../format"; + +const TYPE_LABELS: Record = { + fund: "Fund", + spv: "SPV", + gp: "GP", + mgmt_co: "Mgmt Co", +}; + +const STATUS_COLORS: Record = { + draft: "bg-gray-100 text-gray-600", + submitted: "bg-yellow-100 text-yellow-700", + approved: "bg-green-100 text-green-700", + returned: "bg-red-100 text-red-600", +}; + +export default function EntityOverview() { + const { id } = useParams<{ id: string }>(); + const [entity, setEntity] = useState(null); + const [holdings, setHoldings] = useState([]); + const [totalInvested, setTotalInvested] = useState(0); + const [lastValue, setLastValue] = useState(0); + const [latestRound, setLatestRound] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + loadData(parseInt(id)); + }, [id]); + + async function loadData(entityId: number) { + setLoading(true); + try { + const ent = await api.getEntity(entityId); + setEntity(ent); + + const holds = await api.listHoldings(entityId); + setHoldings(holds); + + let invested = 0; + for (const h of holds) { + const positions = await api.listPositions(h.id); + for (const p of positions) { + invested += p.cost_cents; + } + } + setTotalInvested(invested); + + const rounds = await api.listRounds(entityId); + const approved = rounds.filter((r) => r.status === "approved"); + if (approved.length > 0) { + setLatestRound(approved[0]); + setLastValue(approved[0].valuations.reduce((s, v) => s + v.value_cents, 0)); + } + // Also check for most recent round of any status + if (rounds.length > 0) { + setLatestRound(rounds[0]); + if (rounds[0].status === "approved") { + setLastValue(rounds[0].valuations.reduce((s, v) => s + v.value_cents, 0)); + } + } + } finally { + setLoading(false); + } + } + + if (loading || !entity) { + return
Loading...
; + } + + const gainLoss = lastValue - totalInvested; + + return ( +
+ {/* Header */} +
+
+

{entity.name}

+ + {TYPE_LABELS[entity.type] || entity.type} + +
+ {/* Tabs */} +
+ + Overview + + + Investments + + Partners + Documents +
+
+ + {/* Summary cards */} +
+ + + + + + = 0 ? "+" : "-"}${formatMoney(Math.abs(gainLoss))}`} + color={gainLoss >= 0 ? "text-green-600" : "text-red-600"} + /> +
+ + {/* Current quarter status */} + {latestRound && ( +
+

Current Quarter Status

+
+ {formatQuarter(latestRound.quarter_end)} + + {latestRound.status} + + {latestRound.status === "approved" && latestRound.approved_at && ( + + Signed {formatDate(latestRound.approved_at)} + + )} +
+
+ )} +
+ ); +} + +function SummaryCard({ + label, + value, + color = "text-gray-900", +}: { + label: string; + value: string; + color?: string; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/pages/Investments.tsx b/frontend/src/pages/Investments.tsx new file mode 100644 index 0000000..79b8bc4 --- /dev/null +++ b/frontend/src/pages/Investments.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api, type Entity, type Holding, type Position, type ValuationRound, type Valuation } from "../api"; +import { formatMoney, formatMoneyExact, formatDate, formatQuarter, formatPerShare } from "../format"; + +const TYPE_LABELS: Record = { + fund: "Fund", + spv: "SPV", + gp: "GP", + mgmt_co: "Mgmt Co", +}; + +interface PositionWithValuation extends Position { + lastValueCents: number | null; + valuationDate: string | null; + valuationQuarter: string | null; +} + +interface HoldingGroup { + holding: Holding; + positions: PositionWithValuation[]; + totalCost: number; + totalValue: number; +} + +export default function Investments() { + const { id } = useParams<{ id: string }>(); + const [entity, setEntity] = useState(null); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + loadData(parseInt(id)); + }, [id]); + + async function loadData(entityId: number) { + setLoading(true); + try { + const ent = await api.getEntity(entityId); + setEntity(ent); + + const holdings = await api.listHoldings(entityId); + const rounds = await api.listRounds(entityId); + + // Find latest approved round + const approvedRounds = rounds + .filter((r) => r.status === "approved") + .sort((a, b) => b.quarter_end.localeCompare(a.quarter_end)); + const latestApproved = approvedRounds[0] || null; + + // Build valuation lookup: position_id -> {value_cents, quarter_end, approved_at} + const valMap = new Map(); + if (latestApproved) { + for (const v of latestApproved.valuations) { + valMap.set(v.position_id, { + value_cents: v.value_cents, + quarter_end: latestApproved.quarter_end, + approved_at: latestApproved.approved_at, + }); + } + } + + const result: HoldingGroup[] = []; + for (const h of holdings) { + const positions = await api.listPositions(h.id); + const enriched: PositionWithValuation[] = positions.map((p) => { + const val = valMap.get(p.id); + return { + ...p, + lastValueCents: val?.value_cents ?? null, + valuationDate: val?.approved_at ?? null, + valuationQuarter: val?.quarter_end ?? null, + }; + }); + const totalCost = enriched.reduce((s, p) => s + p.cost_cents, 0); + const totalValue = enriched.reduce((s, p) => s + (p.lastValueCents ?? 0), 0); + result.push({ holding: h, positions: enriched, totalCost, totalValue }); + } + + setGroups(result); + } finally { + setLoading(false); + } + } + + if (loading || !entity) { + return
Loading...
; + } + + const totalCost = groups.reduce((s, g) => s + g.totalCost, 0); + const totalValue = groups.reduce((s, g) => s + g.totalValue, 0); + const totalGain = totalValue - totalCost; + const positionCount = groups.reduce((s, g) => s + g.positions.length, 0); + + return ( +
+ {/* Header */} +
+
+

{entity.name}

+ + {TYPE_LABELS[entity.type] || entity.type} + +
+
+ + Overview + + + Investments + + Partners + Documents +
+
+ + {/* Header band */} +
+
+ Active investments:{" "} + {positionCount} +
+
+ Total cost:{" "} + {formatMoney(totalCost)} +
+
+ Total value:{" "} + {formatMoney(totalValue)} +
+
+ Unrealized gain/loss:{" "} + = 0 ? "text-green-600" : "text-red-600"}`}> + {totalGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(totalGain))} + +
+
+ + {/* Grouped table */} +
+ + + + + + + + + + + + + + + + {groups.map((group) => { + const holdingGain = group.totalValue - group.totalCost; + return ( + + ); + })} + +
SecurityDateSharesCostCost/ShareSigned ValueValue/ShareSignedGain/Loss
+
+
+ ); +} + +function HoldingGroupRows({ group, holdingGain }: { group: HoldingGroup; holdingGain: number }) { + return ( + <> + {/* Holding header row */} + + + {group.holding.company_name} + + + {formatMoney(group.totalCost)} + + + + {formatMoney(group.totalValue)} + + + + = 0 ? "text-green-600" : "text-red-600"}`}> + {holdingGain >= 0 ? "+" : "-"}{formatMoney(Math.abs(holdingGain))} + + + {/* Position rows */} + {group.positions.map((pos) => { + const gain = (pos.lastValueCents ?? 0) - pos.cost_cents; + return ( + + {pos.security_name} + {formatDate(pos.investment_date)} + + {pos.shares ? parseFloat(pos.shares).toLocaleString() : "—"} + + {formatMoney(pos.cost_cents)} + + {formatPerShare(pos.cost_cents, pos.shares)} + + + {pos.lastValueCents != null ? formatMoney(pos.lastValueCents) : "—"} + + + {pos.lastValueCents != null ? formatPerShare(pos.lastValueCents, pos.shares) : "—"} + + + {pos.valuationQuarter + ? `${formatQuarter(pos.valuationQuarter)} ${pos.valuationDate ? formatDate(pos.valuationDate) : ""}` + : "—"} + + = 0 ? "text-green-600" : "text-red-600"}`}> + {pos.lastValueCents != null ? ( + <> + {gain >= 0 ? "▲" : "▼"} {formatMoney(Math.abs(gain))} + + ) : ( + "—" + )} + + + ); + })} + + ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..113d3f6 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from "react"; +import { useAuth } from "../context/AuthContext"; + +export default function Login() { + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await login(email, password); + } catch (err: any) { + setError(err.message || "Login failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Ten31Portal

+
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" + required + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/frontend/src/pages/ValuationWorkflow.tsx b/frontend/src/pages/ValuationWorkflow.tsx new file mode 100644 index 0000000..069ac3b --- /dev/null +++ b/frontend/src/pages/ValuationWorkflow.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { + api, + type Entity, + type Holding, + type Position, + type ValuationRound, + type Valuation, +} from "../api"; +import { useAuth } from "../context/AuthContext"; +import { formatMoney, formatMoneyExact, formatQuarter, formatDate } from "../format"; + +const STATUS_COLORS: Record = { + draft: "bg-gray-100 text-gray-600", + submitted: "bg-yellow-100 text-yellow-700", + approved: "bg-green-100 text-green-700", + returned: "bg-red-100 text-red-600", +}; + +interface PosInfo { + position: Position; + holdingName: string; +} + +export default function ValuationWorkflow() { + const { id } = useParams<{ id: string }>(); + const { user } = useAuth(); + const [entity, setEntity] = useState(null); + const [rounds, setRounds] = useState([]); + const [selectedRound, setSelectedRound] = useState(null); + const [posMap, setPosMap] = useState>(new Map()); + const [editValues, setEditValues] = useState>(new Map()); + const [priorValues, setPriorValues] = useState>(new Map()); + const [returnNote, setReturnNote] = useState(""); + const [newQuarter, setNewQuarter] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + const entityId = id ? parseInt(id) : 0; + const isApprover = user?.role === "approver"; + const canEdit = + selectedRound && + (selectedRound.status === "draft" || selectedRound.status === "returned") && + user && + ["fund_admin", "cfo", "approver"].includes(user.role); + const canSubmit = canEdit; + const canApprove = + selectedRound?.status === "submitted" && isApprover; + const canReturn = + selectedRound?.status === "submitted" && isApprover; + + useEffect(() => { + if (!entityId) return; + loadData(); + }, [entityId]); + + async function loadData() { + setLoading(true); + try { + const ent = await api.getEntity(entityId); + setEntity(ent); + + const rds = await api.listRounds(entityId); + setRounds(rds); + if (rds.length > 0) selectRound(rds[0], entityId); + + // Build position map + const holdings = await api.listHoldings(entityId); + const pm = new Map(); + for (const h of holdings) { + const positions = await api.listPositions(h.id); + for (const p of positions) { + pm.set(p.id, { position: p, holdingName: h.company_name }); + } + } + setPosMap(pm); + + // Build prior approved values map + const approvedRounds = rds.filter((r) => r.status === "approved"); + if (approvedRounds.length > 0) { + const latest = approvedRounds[0]; + const pv = new Map(); + for (const v of latest.valuations) { + pv.set(v.position_id, v.value_cents); + } + setPriorValues(pv); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + + function selectRound(round: ValuationRound, _entityId?: number) { + setSelectedRound(round); + const ev = new Map(); + for (const v of round.valuations) { + ev.set(v.position_id, (v.value_cents / 100).toString()); + } + setEditValues(ev); + setError(""); + } + + async function handleCreateRound() { + if (!newQuarter) return; + setError(""); + try { + const round = await api.createRound(entityId, newQuarter); + setRounds((prev) => [round, ...prev]); + selectRound(round); + setNewQuarter(""); + } catch (err: any) { + setError(err.message); + } + } + + async function handleSave() { + if (!selectedRound) return; + setSaving(true); + setError(""); + try { + const valuations = Array.from(editValues.entries()).map(([posId, dollars]) => ({ + position_id: posId, + value_cents: Math.round(parseFloat(dollars) * 100), + })); + const updated = await api.updateValuations(selectedRound.id, valuations); + setSelectedRound(updated); + setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleSubmit() { + if (!selectedRound) return; + await handleSave(); + try { + const updated = await api.submitRound(selectedRound.id); + setSelectedRound(updated); + setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + } catch (err: any) { + setError(err.message); + } + } + + async function handleApprove() { + if (!selectedRound) return; + try { + const updated = await api.approveRound(selectedRound.id); + setSelectedRound(updated); + setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + } catch (err: any) { + setError(err.message); + } + } + + async function handleReturn() { + if (!selectedRound || !returnNote.trim()) return; + try { + const updated = await api.returnRound(selectedRound.id, returnNote); + setSelectedRound(updated); + setRounds((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + setReturnNote(""); + } catch (err: any) { + setError(err.message); + } + } + + if (loading || !entity) { + return
Loading...
; + } + + return ( +
+
+
+

{entity.name}

+
+
+ + Overview + + + Investments + + + Valuation + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Round list sidebar */} +
+
+

New Round

+
+ setNewQuarter(e.target.value)} + className="flex-1 px-2 py-1.5 border border-gray-300 rounded text-sm" + placeholder="Quarter end" + /> + +
+
+ +

Rounds

+
    + {rounds.map((r) => ( +
  • + +
  • + ))} + {rounds.length === 0 && ( +
  • No rounds yet.
  • + )} +
+
+ + {/* Round detail */} + {selectedRound ? ( +
+
+
+

+ {formatQuarter(selectedRound.quarter_end)} +

+ + {selectedRound.status} + +
+
+ {canEdit && ( + <> + + + + )} + {canApprove && ( + + )} +
+
+ + {selectedRound.return_note && selectedRound.status === "returned" && ( +
+ Return note: {selectedRound.return_note} +
+ )} + + {selectedRound.status === "approved" && selectedRound.approved_at && ( +
+ Approved by user #{selectedRound.approved_by} on {formatDate(selectedRound.approved_at)} +
+ )} + + {/* Valuations table */} +
+ + + + + + + {canApprove && ( + + )} + + {canApprove && ( + + )} + + + + {selectedRound.valuations.map((v) => { + const info = posMap.get(v.position_id); + const prior = priorValues.get(v.position_id) ?? 0; + const currentCents = canEdit + ? Math.round(parseFloat(editValues.get(v.position_id) || "0") * 100) + : v.value_cents; + const delta = currentCents - prior; + + return ( + + + + + {canApprove && ( + + )} + + {canApprove && ( + + )} + + ); + })} + {selectedRound.valuations.length === 0 && ( + + + + )} + +
CompanySecurityCostPrior Value + {canEdit ? "Value (edit)" : "Value"} + Delta
{info?.holdingName || "—"}{info?.position.security_name || "—"} + {info ? formatMoney(info.position.cost_cents) : "—"} + + {formatMoney(prior)} + + {canEdit ? ( + { + const next = new Map(editValues); + next.set(v.position_id, e.target.value); + setEditValues(next); + }} + className="w-32 px-2 py-1 border border-gray-300 rounded text-right text-sm" + /> + ) : ( + {formatMoney(v.value_cents)} + )} + = 0 ? "text-green-600" : "text-red-600"}`}> + {delta >= 0 ? "+" : "-"}{formatMoney(Math.abs(delta))} +
+ No positions in this round. +
+
+ + {/* Return form for approvers */} + {canReturn && ( +
+

Return with note

+
+ setReturnNote(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm" + placeholder="Reason for returning..." + /> + +
+
+ )} +
+ ) : ( +
+ Select or create a round. +
+ )} +
+
+ ); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4d35deb --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": "http://localhost:8000", + }, + }, +});